Get in touch
Gabs
Blog

Why does your web font still cause layout shift?

A size-adjusted fallback still leaves ch widths changing at the swap. In my test a 14ch heading scored 0.0244 CLS; the em cap scored 0.

Gabriel EspinheiraSenior Full-Stack Engineer
  • Published
  • 6 minReading time
  • DesignCategory

Listen to this post· AI-narrated

0:00 / 7:42

You set font-display: swap. You gave the web font a fallback with size-adjust and metric overrides, or let Next.js generate one, and its docs promise you can "optimally load web fonts with no layout shift". The heading still jumps when the font loads. I built a sample page to find out why, using the exact fallback next/font generates for Newsreader, a Google Font. The fallback was fine. The layout shift came from one line of CSS: a max-width in ch.

TL;DR: A size-adjusted fallback matches your web font's average letter width. The ch unit is measured from the "0" alone, which the fallback leaves unmatched. In my test a heading capped at 14ch was about 14% narrower before the swap, lost a line when the font arrived and scored 0.0244 CLS. The same cap in em scored 0.

What does a size-adjusted fallback actually match?

A size-adjusted fallback matches one number: how much space a line of ordinary text takes. For Newsreader, next/font/local with adjustFontFallback: 'Times New Roman' (the option that tells Next.js to build the fallback from Times New Roman) generates this:

@font-face {
  font-family: "Newsreader Fallback";
  src: local("Times New Roman");
  ascent-override: 73.61%;
  descent-override: 26.54%;
  line-gap-override: 0%;
  size-adjust: 99.84%;
}

The three overrides line up the vertical metrics. The 99.84% scale makes Times New Roman's average letter as wide as Newsreader's, so a paragraph wraps in nearly the same places in both. No setting here looks at any single glyph.

I measured the "0" in both fonts in Chromium: 0.578em in Newsreader, 0.499em in the adjusted fallback. Newsreader draws a wide zero and Times New Roman a narrow one, and a 99.84% scale barely moves that. Glyph widths come from the font files, so your own font pair will have its own gap; the console snippet further down measures it.

Why does a ch width change when the web font loads?

The ch unit is the width of the "0" glyph in the font the element is using right now. CSS Values Level 4 (opens in a new tab) defines it that way, and that's why it's handy for line length: 65ch is roughly 65 characters of whatever font you set.

During a swap, "the font you set" changes. Before the web font arrives, 1ch is the fallback's zero. After, it's Newsreader's. At 40px:

max-width: 14chBox widthHeading lines
Fallback showing279.5px4
Newsreader loaded323.6px3

The box is about 14% narrower until the font loads. The words inside it are about the same width in both fonts, because size-adjust did its job (the em-capped version below wraps to three lines in both), so a narrower box can only mean more line breaks. Cloud Four hit the same thing in 2023 (opens in a new tab) on Cloudinary's blog, where post content capped at 50ch shifted every time Inter swapped in. They planned to try size-adjust next. With Newsreader's generated fallback, size-adjust was already on and the box still changed width.

How much layout shift did the ch heading cause?

I ran three versions of the page in Chromium with a mobile profile: 390×844, 4x CPU slowdown, 150 ms latency and 1.6 Mbps down, cache off, three runs each. The only change between them is the heading's cap.

Heading capCLS (3 runs)
max-width: 14ch0.0244, 0.0244, 0.0244
max-width: 8.09em0, 0, 0
14ch, no web font at all0, 0, 0

The page painted at about 280 ms in the fallback. The font arrived at about 600 ms, the heading lost a line, and the paragraph and button under it jumped up 44px. The third row is the control: with no swap, 14ch never changes, so it never shifts.

On its own, 0.0244 is inside web.dev's "good" limit of 0.1 (opens in a new tab). It's also about a quarter of that limit, spent on one heading of a page whose fallback was already matched. Larger type, a centered hero or a content column in ch moves more of the screen, and CLS grows with the area that moves.

Why does the shift appear on some headings and not others?

Font layout shift from ch depends on your copy, so a page can pass a test today and fail next week. I tried seven headlines in the same 14ch box. Three of them wrapped onto an extra line in the fallback. The other four broke in the same places in both fonts, so the narrower box changed nothing anyone could see, and CLS stayed at 0.

The width difference is always there. A shift only shows up when it moves a line break. Change the hero copy and the jump can come back with no CSS change in the diff. That's why I'd remove ch from swapping text even where it measures clean today.

How do you fix a ch width without losing the measure?

Convert the cap into a unit that doesn't change with the font: em, rem or px. To keep the line length you designed, multiply by the web font's zero width. For Newsreader, 14 × 0.578em is about 8.09em:

h1 {
  /* was: max-width: 14ch; 14 × Newsreader's 0.578em zero */
  max-width: 8.09em;
}

em follows the heading's own font-size, so it scales like ch did, and it's the same before and after the swap. To get the ratio for your own font, measure the zero once in the browser console after the font loads:

const probe = Object.assign(document.createElement("span"), { textContent: "0".repeat(100) });
probe.style.cssText = "position:absolute;font:100px YourFont";
document.body.append(probe);
console.log(probe.getBoundingClientRect().width / 100 / 100, "em per ch");
probe.remove();

The trade-off: the em value is tied to one font. Change the typeface and you recompute it. If your text uses a system font stack and never swaps, ch is safe and you can keep it.

Where to look for ch widths in your own CSS

Start with widths and max-widths in ch on anything that can show a web font above the fold. The ex unit (the x-height of the font in use) changes at the swap the same way, so search for it too. If you use Tailwind, max-w-prose is 65ch in Tailwind CSS v4.3.3, which puts every prose column that uses it on the list.

Then watch it happen. In Chrome DevTools, throttle the network, turn on layout shift regions in the Rendering panel and reload: a ch box shows up as text rewrapping at the moment the font lands. If you measure in Playwright, watch the context options. With isMobile: true, Playwright 1.64 reported this shift with hadRecentInput set, so CLS read 0 in all three runs. Without that flag the same page scored 0.0244.

What to do next

If you already matched your fallback and still see a jump, check the units before you tune the fallback again. Search your CSS for ch, convert each cap above the fold to em with your font's zero ratio, and rerun with a slow network so the fallback paints first.

The CLS numbers above are Chromium's; the zero widths are the fonts' own, so the box changes width in any browser that swaps fonts. More front-end tests like this one are on the gabs.app blog. I'm a full-stack engineer, and gabs.app has my work; if you're hiring, the contact form reaches me directly.