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-facerules 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.
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.
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:
| Configuration | CLS | Swap visible at | Notes |
|---|---|---|---|
Google Fonts stylesheet, swap | 0.14 | 1.42 s | Extra DNS + connection before the font request starts |
Self-hosted, swap, no overrides | 0.11 | 0.98 s | Earlier swap, same reflow |
| Self-hosted + preload, no overrides | 0.09 | 0.61 s | Shorter fallback window, shift unchanged in size |
| Self-hosted + preload + overrides | 0.00 | 0.61 s | Line boxes identical across the swap |
Self-hosted + font-display: optional | 0.00 | n/a | No swap on a slow connection |
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-faceblock 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.
Related
- Parent: Cumulative Layout Shift Fixes for Static Sites — the other three causes of shift.
- Self-Hosting Google Fonts to Eliminate Layout Shift — the delivery half of this fix.
- Subsetting Variable Fonts for Faster First Render — get the file under 40 KB.
- Reserving Space for Images and Embeds — fix this first; it is usually the bigger number.
- Font Loading Strategies for Static Sites — the wider loading policy this fits into.