All postsEngineering

How we stop bad line breaks on a Czech and English website

CSS text-wrap handles about half of it. The rest is no-break spaces applied in one place, plus a few rules for headlines. This is the code we run on steezr.com.

Johnny Unar8 min read

01

the browser only knows about width

A browser breaks a line at the last space that fits. It has no idea that "550 Kč" is one quantity, that a Czech reader trips over a line ending in a lone "v", or that "Good code. Bad influence." reads better split at the full stop than as "Good code. Bad / influence." Unless you tell it otherwise, every responsive layout produces a few broken-looking lines at some width, and because the breaks move with the viewport, nobody on the team sees the same ones.

On a bilingual site, this matters more than people expect. English forgives a lot, but Czech typography has firm conventions: one-letter prepositions and conjunctions (k, s, v, z, o, u, a, i) shouldn't end a line, a number stays with its unit, a date like "1. 1. 2026" doesn't split, and a dash never starts a line. A Czech reader notices these the way an English reader notices a typo in a headline. They keep reading, but they quietly mark the site down.

What follows is the setup we run on steezr.com: a short CSS layer, one typesetting function of about twenty lines, and a few rules for display text. None of it needs a library.

02

two lines of css do the first half

Modern CSS has two values of text-wrap that handle most of the ragged-edge problem:

css
1h1, h2, h3, h4, h5, h6 {
2 text-wrap: balance;
3}
4
5p, li, dd, dt, blockquote, figcaption, td, th {
6 text-wrap: pretty;
7}

balance evens out the line lengths in a block, which suits headlines, where a long line followed by a one-word stub looks like a mistake. pretty is aimed at body copy and mainly avoids leaving a single word alone on the last line. Chromium only adjusts the last few lines of a paragraph to get there, while Safari evaluates the whole thing.

Both have limits. Balancing is expensive, so browsers cap it: Chromium balances blocks of up to six lines and Firefox up to ten, and anything longer silently falls back to normal wrapping. That's fine for headings and useless for a long intro paragraph, which is why long text gets pretty instead. balance works in every current engine, and pretty ships in Chromium and Safari 26 but not yet in Firefox. Browsers that don't know a value just wrap normally, so there's no reason to wait for full support.

Neither value understands the content, though. pretty will happily end a line with "v" if that gives a nicer rag, because to the browser "v" is a short word like any other.

03

the other half is no-break spaces

The fix for words that belong together is old and boring: put a no-break space (U+00A0) between them instead of a normal one. The browser then treats "v Praze" or "550 Kč" as one unbreakable word, so the pair moves to the next line together.

Doing this by hand doesn't work. Copywriters won't type them, CMS editors strip them or turn them into   entities that show up escaped on the page, and translators never see them at all. We apply them with a function at render time instead, so the source text stays plain and the rules live in one file anyone can read.

The function is a list of regex rules, and each one turns one specific kind of space into a no-break space:

ts
1const NBSP = "\u00A0";
2
3const RULES: [RegExp, string][] = [
4 // one-letter words and short English articles
5 [/(?<=^|[\s(\["'>])([ksvzouaiKSVZOUAI]|an|An|the|The)\s+/gu, `$1${NBSP}`],
6 // initials and titles before a name: "J. Novák", "Ing. Nováková"
7 [/(?<=^|[\s(])(\p{Lu}\.|Ing\.|Mgr\.|Bc\.|Dr\.)\s+(?=\p{L})/gu, `$1${NBSP}`],
8 // abbreviations that lead a number or word: "č. 5", "např. ", "§ 12"
9 [/(?<=^|[\s(])(č\.|tj\.|např\.|§)\s+/gu, `$1${NBSP}`],
10 // ordinal day and month: "14. července", "1. 1. 2026"
11 [/(?<=^|[\s(])(\d{1,2}\.)\s+(?=[\d\p{L}])/gu, `$1${NBSP}`],
12 // thousands groups and phone numbers: "1 000 000", "+420 777 123 456"
13 [/(\d)\s+(?=\d{2,3}(?!\d))/gu, `$1${NBSP}`],
14 // a number and its unit: "550 Kč", "10 %"
15 [/([\d#])\s+(?=[\p{L}%€$])/gu, `$1${NBSP}`],
16 // a dash never starts a line
17 [/\s+(?=[–—])/gu, NBSP],
18];
19
20export function typeset(text: string): string {
21 return RULES.reduce((out, [re, to]) => out.replace(re, to), text);
22}

Our real file has a few more abbreviations and a rule that keeps "s. r. o." with the company name, but the shape is the same.

Two details aren't obvious. The first rule checks the preceding boundary with a lookbehind instead of consuming it, and that matters for chains. In "a v Praze", both spaces need gluing. If the regex ate the space before "v" while matching "a ", the next match couldn't start there and "v" would stay breakable. With lookbehinds the matches don't overlap, so "a v Praze" and "1. 1. 2026" glue all the way through in one pass. It also helps that \s in JavaScript matches U+00A0, so a space we've already glued still counts as a boundary for the next rule.

The second detail is the # in the unit rule. next-intl messages use ICU plurals like {count, plural, other {# projects}}, and the function runs on the raw messages before ICU fills in the number, so # stands in for the digit.

These rules over-match, and we're fine with that. The thousands rule turns "in 2026 150 people" into one glued run from "2026" to "people", which is technically wrong and costs nothing, because the worst case is a line breaking one word earlier. A missed glue costs a visible defect, so when a rule is ambiguous we let it match too much.

04

run it once, in the right places

The function only helps if it runs on all text without anyone remembering to call it. On this site, that happens in three places.

All UI copy goes through next-intl, and the request config typesets the whole message tree when it loads:

ts
1export default getRequestConfig(async ({ requestLocale }) => {
2 // ...locale resolution
3 return {
4 locale,
5 messages: typesetDeep((await import(`../messages/${locale}.json`)).default),
6 };
7});

typesetDeep walks objects and arrays and applies typeset to every string, so a new message is covered the moment someone adds it. Content loaded from files or a CMS, like these blog posts, gets typeset in the data layer that loads it. The date formatter typesets its own output too, because Intl.DateTimeFormat returns "14. července 2026" with ordinary spaces.

Where it must not run matters just as much. Code samples are the obvious case: a no-break space inside a code block survives copy-paste, so the pasted snippet looks right and then fails to parse or matches the wrong thing. Our article renderer splits each section into code fences, inline code and prose, and typesets only the prose.

The nastier trap is code that splits text on whitespace after typesetting. Word-by-word animations, scramble effects and "wrap every word in a span" helpers usually call text.split(/\s+/), and since \s matches U+00A0, the split quietly undoes every glue and turns each word into its own inline element that can wrap anywhere. Our splitter matches breakable whitespace only.

ts
1const SPACE = /[ \t\n]/;

With that split, "v Praze" survives as one animated word.

05

headlines need more than balance

Large display type has problems balance doesn't solve. A headline made of two sentences, like "Good code. Bad influence.", should break after the full stop, but balance picks whichever split makes the two lines most even, and that can be "Good code. Bad / influence." The reliable fix is to wrap each phrase in its own span with display: inline-block, so a phrase wraps as a unit when there's room and the phrases stack when there isn't. Our word splitter groups words into sentences, and sentences into runs of glued words, for exactly this reason.

Phrases with a marker background or a pill behind them need white-space: nowrap when they're short, because a highlight broken across two lines looks like two separate highlights. If a phrase is long enough that it has to wrap, box-decoration-break: clone at least gives each fragment its own padding and rounded corners.

The last problem is the long word. We don't hyphenate headings, so on a 360-pixel phone a 7rem heading containing "Automatizace" simply overflows. Instead of writing a breakpoint per heading, we cap the font size by the longest unbreakable run in the text:

ts
1export function fitLongestWord(size: string, text: string): string {
2 const longest = Math.max(
3 ...text.replace(/<\/?\w+>/g, "").split(/[ \t\n]+/).map((w) => w.length),
4 );
5 return `min(${size}, calc((100vw - 3rem) / ${longest * 0.75}))`;
6}

The 0.75 is the average character width of our display face at weight 800, in em, and 3rem is the page's horizontal padding. Both are specific to our design, so measure your own font before copying them. The split uses the same breakable-only whitespace, so a glued pair like "v Praze" counts as one long word, which is correct because the browser can't break it either.

06

how to check it

The failure you're most likely to introduce is horizontal overflow. Every glue rule makes some run of text longer and unbreakable, and on a 320-pixel screen a long chain, like a phone number with a country code or a company name with its legal form, can push past the column. Check the narrowest width you support after adding a rule, and put overflow-wrap: anywhere on anything that holds URLs, email addresses or hashes.

If you allow hyphenation in body text, remember that hyphens: auto only works when the element or the html tag carries the right lang attribute. A Czech page marked lang="en" gets no hyphenation and no error, and the same goes for a Czech quote inside an English article.

typeset is a pure function, so the rules are easy to unit test, and a short table of inputs and expected outputs catches regressions when someone tightens a regex later:

ts
1import { test } from "node:test";
2import assert from "node:assert/strict";
3import { typeset } from "./typography.ts";
4
5const NBSP = "\u00A0";
6
7test("glues chains of short words", () => {
8 assert.equal(typeset("a v Praze"), `a${NBSP}v${NBSP}Praze`);
9});
10
11test("keeps numbers with units and dates together", () => {
12 assert.equal(typeset("550 Kč"), `550${NBSP}Kč`);
13 assert.equal(typeset("1. 1. 2026"), `1.${NBSP}1.${NBSP}2026`);
14});

Tests tell you a break is allowed, not that it reads well, so the last step is looking. We check every new headline at phone, tablet and desktop widths before it ships.

Thanks for reading.
Johnny Unar

Written by

Johnny Unar

Keep reading

Stop patching.Start building.

Tell us what's slowing you down. We'll bring coffee and opinions.