With font-display: swap, the browser shows text in a fallback font first and swaps in the web font when it arrives. If the two fonts take up different space, the text reflows and everything below it moves. CSS has four descriptors to make a local fallback font take up the same space: size-adjust, ascent-override, descent-override and line-gap-override.

I wanted to know how big the jump really is for the fonts I use on demo sites, and whether the fix works, so I measured it.

The setup

One paragraph of 250 characters at 17px, set in three Google fonts: Hanken Grotesk, Manrope and Space Grotesk. The fallback is Arial. I ran this in headless Chromium on Linux, where Arial isn't installed, so I used Liberation Sans, which was made to have the same metrics as Arial.

Two column widths: 343px (a phone with 16px margins) and 560px. Two line heights: 1.6 and normal.

How far the text moves with a plain fallback

Paragraph height, real font vs. plain fallback:

Font 343px, line-height: 1.6 343px, normal 560px, 1.6 560px, normal
Hanken Grotesk 163.1 vs 163.1 132 vs 120 108.8 vs 108.8 88 vs 80
Manrope 163.1 vs 163.1 138 vs 120 108.8 vs 108.8 92 vs 80
Space Grotesk 190.3 vs 163.1 154 vs 120 108.8 vs 108.8 88 vs 80

Two different things are going on.

With line-height: normal, every font shifts, even when the line breaks are identical. "Normal" means "use the font's own ascent, descent and line gap", and those differ: Manrope's ascent and descent add up to 1.37 times the font size; Arial's ascent and descent are 1.12, about 1.15 with its line gap. So every line grows when Manrope arrives. Here that's 12 to 18px for one paragraph, more on a page of them.

With a fixed line-height: 1.6, only the line count matters. Each line is 27.2px whatever the font, so Hanken and Manrope don't move the paragraph at all. Space Grotesk does: it's about 8% wider than Arial, so at 343px the text needs 7 lines instead of 6 and the paragraph grows by one line, 27.2px.

So the first fix is free: set a unitless line height on body text. That removes the line-box part of the shift for every font; what's left comes from line breaks.

Writing the fallback @font-face

For the width, size-adjust scales the fallback's glyphs. For the line box, the three overrides set the ascent, descent and line gap the fallback should report. Both can be measured in the console once the web font has loaded:

async function fallbackFor(family, localName = 'Arial') { await document.fonts.load(`100px "${family}"`); const sample = 'Small-batch beans roasted every Tuesday in our Portland shop, shipped the same week so they reach you while the flavor is still bright.'; const ctx = document.createElement('canvas').getContext('2d'); const measure = (font) => { ctx.font = `100px ${font}`; return ctx.measureText(sample); }; const web = measure(`"${family}"`); const local = measure(`"${localName}"`); const sizeAdjust = web.width / local.width; const pct = (n) => (n * 100).toFixed(1) + '%'; return `@font-face { font-family: "${family} Fallback"; src: local("${localName}"); size-adjust: ${pct(sizeAdjust)}; ascent-override: ${pct(web.fontBoundingBoxAscent / 100 / sizeAdjust)}; descent-override: ${pct(web.fontBoundingBoxDescent / 100 / sizeAdjust)}; line-gap-override: 0%; }`; } 

Two details:

  • The overrides are divided by sizeAdjust. They're percentages of the font size, and the font size of the fallback is already scaled by size-adjust, so without the division the line box comes out too tall by the same factor.
  • The width ratio comes from a real sentence, not the alphabet. Letter frequency matters: a font with a wide "m" and narrow "i" averages differently on prose than on "abcdefghijklmnopqrstuvwxyz".

What it printed (with 'Liberation Sans' as the local name):

Font size-adjust ascent-override descent-override
Hanken Grotesk 100.8% 99.2% 29.7%
Manrope 102.4% 104.5% 29.3%
Space Grotesk 109.3% 89.6% 26.5%

Then use the fallback right after the web font:

body { font-family: "Space Grotesk", "Space Grotesk Fallback", sans-serif; line-height: 1.6; } 

After

Same paragraph, real font vs. adjusted fallback:

Font 343px, 1.6 343px, normal 560px, 1.6 560px, normal
Hanken Grotesk 163.1 vs 163.1 132 vs 132 108.8 vs 108.8 88 vs 88
Manrope 163.1 vs 163.1 138 vs 138 108.8 vs 108.8 92 vs 92
Space Grotesk 190.3 vs 190.3 154 vs 154 108.8 vs 108.8 88 vs 88

Every height matches, including line-height: normal, and Space Grotesk's fallback now wraps to 7 lines like the real font.

What it doesn't fix

Widths only match on average. At 100px, the adjusted fallback for Space Grotesk set my test paragraph on one line within 0.5% of the real font's width (12213px vs 12157px). At 17px, on this Linux machine, it came out 6% wider (2163px vs 2038px on one line), while the plain fallback was 7% narrower. I suspect font hinting rounding each glyph's width to whole pixels at small sizes, but I didn't dig into it. Line breaks matched for my paragraph; a different paragraph can still break on a different word. The fix makes a reflow much smaller and much rarer, not impossible.

The local font has to exist. local("Arial") matches nothing on a machine without Arial; then the fallback face is skipped and the next font in the stack is used, unadjusted. Mac and Windows ship Arial. Many Linux systems don't, but often have Liberation Sans, so src: local("Arial"), local("Liberation Sans"); covers both with the same numbers. A system with only DejaVu Sans would need its own fallback face.

Headings need their own check. A display serif against Georgia or Times needs a separate fallback face and separate numbers.

Short version

  1. Use a unitless line height for body text. That alone removed the vertical shift for two of the three fonts.
  2. For the rest, generate a fallback @font-face with size-adjust and the three overrides, measured on real text, with the overrides divided by size-adjust.
  3. Measure the result: paragraph heights at a phone width and a desktop width, real font vs. fallback.

If you're doing this for a site you didn't build, you first need to know which fonts it renders and which weights. I made a Chrome extension, Stylasso, that lists every font a page actually renders, with its role, weights and where it loads from; there's a guide here: what font is this website using. DevTools' Rendered Fonts gives you the same for one element at a time.

Written with help from an AI assistant. Every number comes from running the snippet and layout tests described above in headless Chromium.