Our body text runs 98 characters per line, and on a phone 84 of our headings end on a word of their own
Every measurement I have made of this site so far has been of things in the document: elements, bytes, colours, focus order. A line of text is not in the document. It is produced by the layout engine at a particular widt
Every measurement I have made of this site so far has been of things in the document: elements, bytes, colours, focus order. A line of text is not in the document. It is produced by the layout engine at a particular width with a particular font, and the only way to know how long your lines are is to ask the engine after it has decided.
You can. A Range over a single character has a rect, and characters on the same line share a top edge:
const range = document.createRange();
const lines = new Map();
for (let i = 0; i < textNode.textContent.length; i++) {
range.setStart(textNode, i);
range.setEnd(textNode, i + 1);
const r = range.getBoundingClientRect();
const key = Math.round(r.top); // one bucket per line box
lines.set(key, (lines.get(key) || 0) + 1);
}
That gives the real line boxes, with their character counts and their pixel widths, after wrapping, at whatever viewport you emulate. It costs 11 ms per page. I ran it over every paragraph, list item and heading inside <main> on all 79 pages of our site, at 1280 px and again at 390 px.
Desktop: 98 characters per line
| at 1280 px | |
|---|---|
| text blocks measured | 3,045 |
| line boxes | 4,452 |
body blocks (p, li, 15px and up) |
1,502 |
| their line boxes | 2,791 |
| full lines (every line but each block's last) | 1,289 |
| characters per full line | median 98, mean 96.9, p90 102, max 108 |
| full lines over 80 characters | 1,278 of 1,289 (99.1 per cent) |
| full lines over 90 characters | 1,173 |
Typographic advice for a comfortable measure has been 45 to 75 characters for about as long as there has been typographic advice. WCAG 1.4.8 puts the accessibility ceiling at 80 for AAA. Our full lines are 98 characters at the median, and 99.1 per cent of them are over the 80 that is meant to be the outer limit.
The cause is one Tailwind class. The prose container is max-w-3xl:
max-width: 48rem = 768 px
rendered width 720 px on 77 of 79 pages
body font-size 16 px
measured density 7.35 px per character
720 / 7.35 98 characters
768px is a sensible looking number, and it is the default "article width" in half the component libraries in existence, which is how it ends up on sites whose font happens to be narrow. Ours is narrow: the brand typeface never loads, so this text renders in the system sans, and the system sans fits more characters into a pixel than the font we thought we were using. The container was chosen in pixels and the measure is a consequence of the font. There are zero ch units in the 107,598 bytes of CSS we ship.
The arithmetic for a fix is not subtle:
| target | width needed |
|---|---|
| 80 characters (WCAG AAA ceiling) | 588 px |
| 75 characters (top of the classic range) | 551 px |
| 65 characters (the comfortable middle) | 478 px |
Phone: the measure is right and the rags are wrong
| at 390 px | |
|---|---|
| line boxes | 7,079 |
| characters per full line | median 47, mean 46.1, max 57 |
| lines over 80 characters | 0 |
| lines per body block | median 3, mean 3.4, max 12 |
At 390 px the container is 358 px wide and lines are 47 characters, which is inside every guideline there is. The phone layout has better typography than the desktop one, which is the opposite of what I assumed when I started measuring.
What goes wrong on the phone is the ragged edge.
| desktop 1280 | phone 390 | |
|---|---|---|
| multi-line body blocks | 886 | 1,311 |
| ending in a single word | 83 (9.4 per cent) | 243 (18.5 per cent) |
| ending in two words or fewer | 128 | 444 |
| multi-line headings | 13 | 150 |
| headings ending in a single word | 10 | 84 |
Widows are the typographic sin that nobody files a bug for. A paragraph whose last line is the word "it." or "nothing." or "stop." (all real examples from our guides) reads as a stumble, and the narrower the column the more often it happens, which is why the number doubles on a phone.
Headings are worse, because a heading is three to six words and losing one of them to its own line is immediately visible. At 390 px, 150 of our 663 headings wrap, and 84 of those put one word on the second line, which is 56 per cent of every wrapped heading on the site.
The fix is already in the stylesheet, doing nothing
CSS has had answers to both of these for a couple of years now.
h1, h2, h3 { text-wrap: balance; } /* even lines, no stranded word */
p, li { text-wrap: pretty; } /* same measure, fixes the last line */
Here is the part I did not expect. text-wrap: balance is already in the CSS we ship to every visitor:
.text-balance{text-wrap:balance}
It is used on zero of the 79 public pages. I grepped the delivered HTML of all of them: no element carries the class. Tailwind generated the rule because the string appears exactly once in the whole repository, in a signed-in screen that no public page renders, and the utility then shipped to every page on the site to match nothing. It is one of the 748 never-matched rules I found in an earlier selector census, and it is the only one of them that would have fixed a real defect if it had been applied 20 characters away from where it was.
The rest of the relevant properties are absent entirely. In 107,598 bytes of CSS: 1 text-wrap declaration, 1 hyphens, 1 word-break, 1 overflow-wrap, 0 widows, 0 orphans, 0 ch units.
What I am changing
-
text-wrap: balanceonh1, h2, h3in the base layer. One rule, and 84 stranded heading words on a phone become zero, with no layout change at all on the 513 headings that fit on one line. -
text-wrap: prettyonpandli. Chrome and Safari both honour it, Firefox ignores it, and the failure mode of being ignored is exactly what we have now. - Narrow the prose container from
max-w-3xlto roughly 36rem, which lands the measure around 78 characters, and express it inchso it tracks the font rather than the pixel. Our longest lines are currently 108 characters. - Measure again afterwards, at both widths, because this is the first typographic property on this site that anyone has ever checked with a number instead of an eye.
If you want your own number, this is the whole instrument, pasteable into any page's console:
(() => {
const r = document.createRange(), out = [];
for (const el of document.querySelectorAll('main p, main li')) {
for (const n of [...el.childNodes].filter(n => n.nodeType === 3 && n.textContent.trim())) {
const lines = new Map();
for (let i = 0; i < n.textContent.length; i++) {
r.setStart(n, i); r.setEnd(n, i + 1);
const t = Math.round(r.getBoundingClientRect().top);
lines.set(t, (lines.get(t) || 0) + 1);
}
const v = [...lines.values()];
if (v.length > 1) out.push(...v.slice(0, -1)); // full lines only
}
}
out.sort((a, b) => a - b);
return { fullLines: out.length, median: out[out.length >> 1], max: out.at(-1), over80: out.filter(c => c > 80).length };
})()
Run it on a guide of ours, then on whatever you are shipping. If your median comes back in the nineties, you did not choose that, a default did.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.