Eliminating Layout Shift From Web Fonts

Once images have reserved boxes, the shift that usually remains on a static site is the font swap. The browser paints with a fallback face, the web font arrives, and every line of text re-flows — pushing headings, images and everything below them to new positions. It is invisible in a fast local build and obvious on a cold 3G connection.

The fix has two halves: make the swap arrive sooner, and make it change nothing about the line boxes when it does. This guide covers both, with the CSS to paste and the measurements from a 900-word article template. It sits under Cumulative Layout Shift Fixes for Static Sites and complements the delivery-side advice in Font Loading Strategies for Static Sites.

Prerequisites

  • Self-hosted font files, or at least the ability to add @font-face rules of your own. Third-party stylesheets you cannot edit make overrides impossible — start with Self-Hosting Google Fonts to Eliminate Layout Shift.
  • A browser that reports layout shifts: Chrome DevTools' Performance panel, or the Layout Instability script from the parent guide.
  • A baseline CLS measured on a throttled profile — an unthrottled localhost load often hides the swap entirely.

Why the Swap Moves Things

A font's metrics — ascent, descent, line gap, and the average advance width of its glyphs — determine both how tall each line box is and how many characters fit on a line. When the fallback and the web font disagree on either, the swap re-wraps the paragraph and changes its height. A 900-word article that loses one line per paragraph moves everything below the first paragraph by the height of a line, repeatedly, all the way down the page.

Font metrics that decide line box height A diagram of one line of text showing the baseline, the ascent above it, the descent below it and the line gap. A comparison panel shows the fallback face with a taller ascent producing a 26 pixel line box while the web font produces a 24 pixel line box, a difference of two pixels per line. Four numbers decide whether the swap is visible Handgloves ascent descent baseline line gap adds space between boxes Fallback: Arial ascent 0.905 · line box 26 px 38 lines → 988 px tall Web font: Inter ascent 0.969 · line box 24 px 36 lines → 864 px tall 124 px of movement on one article — every element below the text shifts by that amount
The swap is only free when both faces produce the same line boxes. Overrides let you force that agreement without editing either font file.

The Recipe

1. Declare a metric-matched fallback

Create an @font-face that wraps a local system font and adjusts its metrics to match your web font:

@font-face {
  font-family: 'Inter Fallback';
  src: local('Arial'), local('Helvetica Neue');
  size-adjust: 107.4%;      /* scales glyph advance widths */
  ascent-override: 90.0%;   /* space above the baseline */
  descent-override: 22.4%;  /* space below it */
  line-gap-override: 0%;    /* extra leading between boxes */
}

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2-variations');
  font-weight: 100 900;
  font-display: swap;
}

body { font-family: 'Inter', 'Inter Fallback', system-ui, sans-serif; }

The fallback must come immediately after the web font in the stack — that is the face the browser uses during the swap window, and the only one whose metrics you can control.

2. Compute the override values

The four numbers come from the font's own metrics: divide the web font's ascent, descent and line gap by its units-per-em, and set size-adjust to the ratio of average character widths between the fallback and the web font. You can read them from the file directly:

// node scripts/font-metrics.mjs fonts/inter-var.woff2
import { readFileSync } from 'node:fs';
import * as fontkit from 'fontkit';

const font = fontkit.create(readFileSync(process.argv[2]));
const upm = font.unitsPerEm;
console.log('ascent-override :', (font.ascent / upm * 100).toFixed(1) + '%');
console.log('descent-override:', (Math.abs(font.descent) / upm * 100).toFixed(1) + '%');
console.log('line-gap-override:', (font.lineGap / upm * 100).toFixed(1) + '%');

size-adjust is the one value you tune rather than read: start at 100%, render a long paragraph in each face, and adjust until the two block heights match. Build-time tooling can do all of this automatically — the Fontaine plugin generates the fallback rules during the build for Vite-based generators, which keeps the numbers correct when you change typeface.

3. Preload the face that paints first

Overrides make the swap invisible; preloading makes it early, which matters for perceived quality even at zero CLS:

<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>

Preload only the file the first screen needs — usually one variable body face. Preloading four weights competes with the LCP image for bandwidth and slows the thing you actually want first, a trade-off covered in Optimizing LCP on Astro With Priority Hints.

4. Subset so the file arrives before the reader notices

A full Latin-Extended variable font is 90-140 KB; subset to the characters you actually publish and it drops to 25-40 KB. The mechanics, including how to keep punctuation and symbols from disappearing, are in Subsetting Variable Fonts for Faster First Render.

5. Consider font-display: optional for body text

optional gives the font a 100 ms window; if it misses, the page keeps the fallback for that visit and swaps nothing. This guarantees zero font CLS without any override maths, at the cost of readers occasionally seeing the fallback for a whole session. It is the right default for documentation, where the typeface is not the product.

How font-display values divide the loading timeline Three timelines over three seconds. With block the text is invisible for a three second block period before the font paints. With swap the fallback paints immediately and the font swaps in whenever it arrives, causing a shift. With optional the fallback paints immediately and the font is only used if it arrives within a 100 millisecond window, otherwise it is kept for the next visit. Three policies for the same slow font request block invisible text web font · no shift, late text swap fallback text web font · shift unless metrics match optional 100 ms fallback kept for this visit · no shift at all 0 s 1.5 s 3 s
Every policy trades one cost for another: `block` delays text, `swap` risks a shift, `optional` risks not using your typeface at all. Metric overrides are what make `swap` cost nothing.

Measured Impact

Measured on a 900-word article template, Chrome 4× CPU throttle, fast 3G profile, median of five runs. The site self-hosts one variable face at 38 KB after subsetting:

ConfigurationCLSSwap visible atNotes
Google Fonts stylesheet, swap0.141.42 sExtra DNS + connection before the font request starts
Self-hosted, swap, no overrides0.110.98 sEarlier swap, same reflow
Self-hosted + preload, no overrides0.090.61 sShorter fallback window, shift unchanged in size
Self-hosted + preload + overrides0.000.61 sLine boxes identical across the swap
Self-hosted + font-display: optional0.00n/aNo swap on a slow connection
CLS by font loading configuration A horizontal bar chart on a shared scale. Google Fonts with swap scores 0.14, self-hosted with swap scores 0.11, self-hosted with preload scores 0.09, and self-hosted with preload plus metric overrides scores 0.00. Only the last configuration crosses below the 0.10 good threshold marked by a dashed line. Only the overrides take the score to zero 0.10 Google Fonts + swap 0.14 Self-hosted + swap 0.11 + preload 0.09 + metric overrides 0.00 Shared linear scale · 900-word article, Chrome 4× CPU throttle, fast 3G, median of 5 runs
Self-hosting and preloading shorten the window the fallback is visible, which readers notice — but the score only collapses when the two faces produce identical line boxes.

The pattern is worth internalising: delivery changes move the swap earlier, metric changes make it free. Teams often stop after preloading because the page feels better and are surprised the field CLS barely moves.

Pitfalls & Rollback

  • One override for every face. Body, headings and monospace each need their own fallback rule; a heading at 2.5 rem magnifies a 3% error into several pixels.
  • Fallback listed after system-ui. The browser uses the next available family during the swap. If a generic family sits between the web font and your adjusted fallback, your overrides never render.
  • local() that does not exist. local('Arial') resolves on most desktops but not on many Linux and Android devices; list two or three candidates so the rule is not skipped entirely.
  • Testing on a warm cache. After the first load the font is cached and swaps instantly — always measure with the cache disabled and CPU throttled.
  • Overrides on a third-party stylesheet. You cannot adjust a face declared in a stylesheet you do not control; self-host first.
  • Rollback: the overrides are one @font-face block and one family name in the font stack. Deleting them restores previous behaviour immediately, with no build or cache implications.

Conclusion

Font CLS is a metrics problem, not a loading problem. Self-host and preload so the swap happens early, then declare a metric-matched fallback so the swap changes glyph shapes and nothing else. Verify with a throttled run rather than a warm local load, and if the typeface is not doing brand work, font-display: optional gets you the same zero for a fraction of the effort. Then check what is left with the field-data methods in Measuring CLS in the Field With web-vitals.js.

FAQ

Should I use font-display swap or optional?

Use swap with metric-matched fallback overrides when the typeface matters to the brand, because the reader always gets the real font eventually. Use optional when it does not: the browser gives the font a 100 millisecond window and otherwise keeps the fallback for that page view, which guarantees zero shift at the cost of an occasional unstyled page.

How do I find the right size-adjust value?

Divide the fallback font's average character width by the web font's, or measure empirically by rendering a long paragraph in both faces at the same size and comparing the rendered block height. Tools such as the Fontaine plugin and Malte Ubl's fallback generator compute all four override values from the font file directly.

Does preloading the font remove the shift?

It shortens the window in which the fallback is visible but does not eliminate the shift, because the swap still happens. Preloading plus metric overrides is the combination that gets you to zero: the overrides make the swap invisible and the preload makes it early.

Do variable fonts change any of this?

No, the mechanism is identical — a variable font is still one file that swaps in. It usually helps overall because one variable file replaces four or five static weights, so there are fewer requests racing and fewer faces whose metrics need matching.

Why does my heading still jump when the body text does not?

Headings are usually set in a different face or weight from body text, and each face needs its own metric-matched fallback. A single override tuned for the body font will be wrong for a display face at 2.5 rem, where a small percentage error becomes several pixels of line height.