Visual Craft

Lesson 1 of 5

Typography on the Web

"Typography exists to honour content." — Robert Bringhurst, The Elements of Typographic Style (1992)

Almost everything on a web page is text. Not most things — almost everything. Strip out the images and the chrome and what remains is words, set at a size, on lines of a length, with space between them, in a face someone chose. Which means typography is not a decorative layer applied to a finished interface. It is the interface, for the overwhelming majority of what your users actually read.

Engineers tend to treat this as a designer's department, and there is a reasonable version of that instinct: choosing a typeface is a judgement call and not one most of us are trained for. But the parts that go wrong in production are not judgement calls. They are mechanical, they have right answers, and they are almost all invisible until someone with a slow connection, a large font size, or a long name arrives.

This chapter is about those parts. Why line-height: 24px and line-height: 1.5 behave completely differently in a nested component. Why a web font that arrives 400ms late reflows the entire page and what size-adjust does about it. Why the same paragraph is comfortable at 600px and unreadable at 1200px. And why the ch unit exists.

The thing to carry: line-height must be unitless. A unit inherits its computed value, so a 24px line-height set on a container becomes 24px on a 32px heading inside it — a ratio of 0.75, and the lines overlap. A unitless value inherits the ratio, which is what you meant. One character of difference, and it is the single most common typographic bug in component-based code.

The big question

You set line-height: 24px on a container — a sensible value for 16px body text. A heading inside it, at 32px, renders with its lines overlapping. You did not set a line-height on the heading. What did it inherit, and what would 1.5 have inherited instead?

Line-height must be unitless

Tests — Run & Watch Them Pass

Run to execute the tests…

The fourth test closes the obvious escape route. em is not a fix — it resolves to a length at the parent and then inherits that length, exactly like px. Only a bare number inherits as a ratio.

Everyday analogy

Telling a class "leave three centimetres between your lines" versus "leave one and a half times your letter height". The first is fine until someone writes in large letters, at which point their lines collide and they have followed your instruction exactly. The second scales to whoever is writing. Unitless line-height is the second instruction; a unit is the first, and CSS gives you no warning that you have handed out the wrong one.

Line length is the measurement that matters most

Tests — Run & Watch Them Pass

Run to execute the tests…

Web fonts, and the reflow they cause

Tests — Run & Watch Them Pass

Run to execute the tests…

The third test is the one worth acting on. size-adjust and the metric overrides on your fallback remove the reflow entirely, which used to be a genuine trade-off and is now just work you have not done.

See the failures

Try it Yourself
Loading editor…
Result

Panel 1 reports its own computed values, so you can see the 0.75 ratio rather than take my word for it. In panel 3, note the measured ch width for the actual font — that is why the unit exists.

The engineer's notebook

Four defaults I set before writing any other CSS. line-height unitless, always, with about 1.5 for body and tighter ratios for headings — larger text needs less leading, and a single global value is always wrong for something. max-width in ch on anything that is prose, usually around 66, because it is the one typographic decision with the largest effect on whether text is actually read. font-display: swap plus fallback metric overrides, because the reflow that used to make swap a compromise is now solvable and most teams have not noticed. And a system font stack as the fallback rather than a generic sans-serif, since the generic keyword resolves to something with unpredictable metrics. The habit that catches the most, though, is checking the page with web fonts blocked in devtools — not to see if it looks acceptable, but to see how much it moves. If the layout jumps significantly, real users on slow connections are getting that jump, and it is a measurable part of the layout-shift score Part VI is about.

A little history

@font-face was in the CSS2 specification in 1998, and it took roughly a decade to become usable — not for technical reasons but for licensing ones. Type foundries had a business model built on selling files that were installed locally, and serving a font over the web made the file trivially downloadable by anyone. The impasse broke with WOFF in 2009, a compression wrapper that added metadata foundries could point to as a licensing boundary, plus web-specific licences and services like Typekit that handled enforcement. Within about three years the web went from a handful of "web-safe" faces to essentially the entire commercial type catalogue. What is worth noticing is that the enabling change was not technical capability — browsers could have done this in 1998 — but a commercial arrangement everyone could live with. The web has a lot of that history, and it is a useful corrective to the assumption that platform features are gated by engineering.

In the real world

A publisher's article pages had a layout-shift score that failed Core Web Vitals, and the team spent two sprints on images — dimensions, aspect-ratio, lazy loading — moving the score barely at all. The actual cause was the headline font. Their brand face was noticeably narrower than the fallback, so when it loaded, headlines that had wrapped to three lines rewrapped to two, and everything below jumped up by a line height. On a fast connection the swap happened before anyone looked; on a 3G connection it happened three seconds in, mid-read. The fix was two declarations: a @font-face block for the fallback with size-adjust and metric overrides tuned so the fallback occupied the same space, and font-display: swap. Their score passed within a day. The part I found instructive is that the images work was not wrong — it was just optimising the thing that was easy to see. A font swap is invisible on the machine you develop on, which is precisely the profile of a defect that survives two sprints of attention aimed elsewhere.

Thought experiment

Open a long article on any site you like and measure its line length — count the characters in a full line, or set max-width in devtools until you can. Then read two paragraphs at that width, and two more with the measure narrowed to about 66 characters. The difference is not subtle once you are looking for it, and what you will notice is not that the narrow version is prettier: it is that you stop losing your place. Now ask why so few sites do this, given that it costs one declaration and has been settled typographic practice for five hundred years. My honest read is that a wide measure looks efficient — it fills the space, it feels like using the screen you were given — and the cost is entirely on the reader, invisible in any review and unmeasured by anything. That is a shape you should recognise by now: the decision is made where the cost is not felt.

Common Mistakes

  • line-height with a unit. It inherits a computed length, so a 24px line-height on a 32px heading is a ratio of 0.75 and the lines overlap.

  • Thinking em fixes it. 1.5em resolves to a length at the parent and inherits like px. Only a bare number is a ratio.

  • One global line-height. Larger text needs a tighter ratio; the relationship is inverse.

  • Leaving the browser default. About 1.2 — fine for headings, tight for body text, and a common reason a page feels dense.

  • Measures over 75 characters. Readers lose the return sweep and reread lines without noticing. 1200px is about 141 characters.

  • max-width in px for prose. It is correct only for the font it was measured against; ch tracks the typeface and the user's size.

  • A wide measure with tight leading. The worst combination, and endemic in dashboards.

  • font-display: block (or the default). Up to three seconds of invisible text.

  • swap with no metric overrides. Readable immediately and reflows the page — nearly 8% wider lines in the worked case. size-adjust removes it.

  • Shipping six static font files where one variable font covers the same range, often smaller than two of them.

  • No subsetting. A full Unicode face against a Latin subset was 84% larger.

  • Testing with the font cached. The swap is invisible on your machine, which is why it survives sprints of attention aimed elsewhere.

Interview Corner

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Warm-up
Why should line-height be unitless?
Model answer — Because a unitless value inherits as a ratio and a value with a unit inherits as a computed length. If you set line-height: 24px on a container — perfectly sensible for 16px body text — then a 32px heading inside it inherits 24px, which is a ratio of 0.75. Anything under 1 means consecutive lines are closer together than the glyphs are tall, so descenders collide with the next line's ascenders. With line-height: 1.5, the heading inherits the ratio and computes 48px from its own size, which is what you meant. The trap people fall into next is reaching for em to fix it — 1.5em resolves to a length at the parent and then inherits that length, so it behaves exactly like px. Only a bare number is a ratio. I'd also say a single global line-height is never quite right, because larger text needs a tighter ratio — around 1.5 for body and closer to 1.1 or 1.2 for large headings.
Intermediate
A page fails Core Web Vitals on layout shift and the images are all correctly sized. What else?
Model answer — The web font. If the loaded face has different metrics from the fallback — and it usually does — then when it swaps in, every line rewraps and everything below moves. The effect is biggest on headlines, where a face that's slightly narrower can take a three-line headline down to two and shift the whole article up by a line. It's invisible on a development machine because the font is cached, and it happens three seconds into reading on a slow connection, which is the worst possible moment. The traditional framing was that you choose between block, which hides text for up to three seconds, and swap, which shows a fallback and then reflows — a flash of invisible text versus a flash of unstyled text. That framing is out of date. You can declare a @font-face block for the fallback with size-adjust plus ascent and descent overrides, tuned so the fallback occupies the same space as the real font, and then swap costs nothing in layout. Readable text immediately and no reflow. For anything non-essential, font-display: optional is also worth considering — it uses the fallback for this visit and caches the font for the next, so the shift is guaranteed to be zero.
Advanced
How would you set up typography for a product that has to work everywhere?
Model answer — I'd start from the two decisions that matter most and are cheapest: unitless line-height throughout, and max-width in ch on anything that's prose — around 66, because the return sweep is the hard part of reading and it degrades badly past about 75 characters. ch is the right unit because it's the width of the zero glyph in the current font, so the measure tracks the typeface and the user's font size rather than being correct only for whatever it was measured against. Then a modular scale — one base and one ratio, so sizes are derived rather than chosen individually — expressed as custom properties, with clamp for the larger steps and a rem term in the preferred value so it still respects the user's setting. On loading: subset aggressively with unicode-range, since a full Unicode face against a Latin subset is around 84% larger and most products don't use the scripts they're shipping; prefer one variable font over six static files; font-display: swap with fallback metric overrides so the swap is free. And I'd pick a real system font stack as the fallback rather than the generic sans-serif keyword, because generic resolves to something with unpredictable metrics and you can't tune overrides against it. The check I'd build into review is loading the page with fonts blocked and watching how much it moves — not whether it looks acceptable, but the magnitude of the shift, because that's what real users on slow connections get and it's the part that's invisible locally.
Company lens — how the same topic gets asked
micro1Practical: set type for a content page and justify the measure and leading.
GoogleMechanics: inheritance of line-height, the ch unit, font-display semantics, metric overrides.
MetaSystems: a type scale as tokens across surfaces and locales.
AmazonPerformance: font loading as a layout-shift problem, measured.
Hands-on

Search your stylesheets for line-height with a unit and convert every one to a ratio, then look for headings whose leading changed — those were bugs nobody had reported. Set max-width: 66ch on your longest piece of prose and read it before and after. Next, block web fonts in devtools, reload, and record how far the page moves; then add a fallback @font-face with size-adjust tuned until the shift is gone, and measure again. Check what your font files actually cost — count the weights and styles you ship, compare against a variable font, and check whether you are subsetting. Finally, throttle to slow 3G with the cache disabled and watch your own headlines rewrap; that is the experience the metric is measuring and the one you never see.

Tests — Run & Watch Them Pass

Run to execute the tests…

Summary

  • Almost everything on a page is text, so typography is the interface for most of what users read.
  • line-height must be unitless. A unit inherits a computed length: 24px on a 32px heading is a ratio of 0.75, and lines overlap.
  • em does not fix it — it resolves to a length at the parent and inherits like px.
  • Larger text needs a tighter ratio, so one global line-height is always wrong somewhere. The UA default of ~1.2 is tight for body text.
  • 45–75 characters is the comfortable band; 66 is the classic target, around 561px at typical body sizes.
  • 1200px is about 141 characters — nearly twice the upper bound, and readers lose the return sweep.
  • ch tracks the typeface and the user's size; px is correct only for the font it was measured against.
  • ch is approximate — "0" is wider than average, so 66ch is about 72 real characters.
  • font-display is a choice between failure modes: invisible text (block) or reflowing text (swap).
  • Metric overrides on the fallback remove the reflow. size-adjust plus ascent/descent tuning makes swap free.
  • optional guarantees zero shift at the cost of the first visit being off-brand.
  • Six static files versus one variable font covering the same range, often smaller than two of them.
  • Subsetting was 84% in the worked case; most fonts ship scripts you do not use.
  • Test with fonts blocked and measure the movement, not the appearance — the swap is invisible on your machine.

In the next chapter, Colour & Contrast, we look at why the colour model most of us use daily lies about lightness — and what that costs when you build a palette on it.

Editorial reflection

Line length is the clearest case I know of a settled question that the web keeps re-losing. It was worked out by printers over centuries, it is not controversial, it costs one declaration, and the majority of text on the web still exceeds it — often by a factor of two. When I ask why, the answer is usually that a narrow column looks like wasted space on a wide screen, which is a real perception and completely beside the point: the screen is not the thing being used, the reader is. What makes this stick with me is that it is not an engineering failure or a knowledge failure. Everyone involved could recite the rule. It loses to a visual instinct about filling available space, held by people who are looking at the layout rather than reading it. That is worth watching for generally, because a lot of interface decisions have that structure — the person deciding is in a different relationship to the artefact than the person using it, and the difference is invisible from the deciding chair.

Q&A · 0

Enrol to ask questions and join the discussion.

No questions yet — be the first to ask.