Reducing Layout Shift from Web Fonts
When a web font swaps in, the fallback and the real font almost never share the same x-height and advance widths, so every text block reflows the instant the swap fires — a layout shift the browser records as Cumulative Layout Shift. This page shows how size-adjust, ascent-override, descent-override, and line-gap-override reshape the fallback to match the web font’s box so the swap moves no pixels, plus the f-mods technique and preloading. It builds on Font Loading and Text Rendering and is part of Browser Rendering Pipeline Fundamentals.
The Metric Mismatch
A font’s metrics — units-per-em, ascent, descent, line gap, and per-glyph advance widths — determine exactly how much vertical and horizontal space a run of text occupies. The fallback (Arial, Times) has different metrics than your web font, so text laid out in the fallback wraps and stacks differently. When font-display: swap replaces the fallback, the browser re-runs layout with the new metrics and the block changes height, shoving everything below it.
/* Reproduction: swap with no metric matching — guaranteed reflow */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter.woff2') format('woff2');
font-display: swap; /* fallback shown first, then swaps to Inter */
}
body { font-family: 'Inter', Arial, sans-serif; } /* Arial box ≠ Inter box */
Arial’s x-height and advances are narrower than Inter’s, so a paragraph that wrapped to 12 lines in Arial may wrap to 11 in Inter. The block shrinks, content below jumps up, and the layout instability algorithm scores the movement.
[Main Thread — Inter swap on an article body]
0ms | FCP — Arial fallback, article height = 2140px
940ms | Inter woff2 decoded, render-tree font re-match
942–953ms | Layout (11ms) — article reflows to 2068px
953ms | Paint — 72px upward shift of footer, CLS += 0.09
Metric Overrides
The fix is to declare a second @font-face that aliases the local fallback font but overrides its metrics so it occupies the web font’s exact box. Four descriptors do this, all on the @font-face rule:
size-adjustscales every glyph uniformly so the fallback’s x-height matches the web font’s.ascent-overridepins the ascent (space above the baseline) used for line-box height.descent-overridepins the descent (space below the baseline).line-gap-overridesets the extra leading, almost always to0%.
/* Fix: a metric-matched fallback that occupies Inter's exact box */
@font-face {
font-family: 'Inter'; /* the real web font */
src: url('/fonts/inter.woff2') format('woff2');
font-display: swap;
}
@font-face {
font-family: 'Inter Fallback'; /* aliases a local system font */
src: local('Arial');
size-adjust: 107%; /* scale Arial up so x-heights align */
ascent-override: 90%; /* match Inter's ascent → identical line box */
descent-override: 22%; /* match Inter's descent */
line-gap-override: 0%; /* strip Arial's extra leading */
}
/* Order matters: Inter first, the matched fallback second */
body { font-family: 'Inter', 'Inter Fallback', sans-serif; }
With the overrides applied, the fallback renders in a box dimensionally identical to Inter. When Inter swaps in, glyph shapes change but the box size does not, so layout produces zero displacement and the swap contributes 0.00 to CLS.
The f-mods Technique
The override percentages are not guesses — they are computed from the two fonts’ raw metric tables. The “f-mods” (font modifiers) technique reads ascent, descent, lineGap, and unitsPerEm from both fonts and derives the percentages so the fallback’s line box exactly equals the web font’s:
// Derive override percentages from raw font metrics (build-time)
function fMods(web, fallback) {
const webRatio = web.unitsPerEm
return {
// size-adjust aligns x-heights between the two faces
sizeAdjust: `${((web.xHeight / web.unitsPerEm) /
(fallback.xHeight / fallback.unitsPerEm) * 100).toFixed(2)}%`,
ascentOverride: `${(web.ascent / webRatio * 100).toFixed(2)}%`,
descentOverride: `${(Math.abs(web.descent) / webRatio * 100).toFixed(2)}%`,
lineGapOverride: `${(web.lineGap / webRatio * 100).toFixed(2)}%`,
}
}
Tooling automates exactly this: Next.js next/font, Fontaine, and the Capsize library parse the metric tables and emit the matched @font-face block, so you rarely hand-author the numbers. The mechanism is identical regardless of tool — a fallback whose line box equals the web font’s.
Preload to Shorten the Swap Window
Metric overrides eliminate the shift; preloading shrinks the window during which fallback text is visible at all. Starting the fetch during HTML parse rather than after render-tree matching often lets the web font arrive before or near FCP, so under optional it can be used immediately and under swap the fallback flash is brief.
<!-- crossorigin is required: font fetches are always CORS-mode -->
<link rel="preload" href="/fonts/inter.woff2" as="font"
type="font/woff2" crossorigin>
Combine the two: preload narrows the visible fallback period, and the metric-matched fallback guarantees that whenever the swap does happen, nothing moves. This is the same strategy that keeps CSS off the critical path in Optimizing critical CSS for faster first paint — get the resource early, and make its arrival cost nothing.
Verification
Confirm the swap moves zero pixels with a layout-shift observer scoped to the load window:
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// hadRecentInput filters user-driven shifts; only unexpected ones count to CLS
if (!entry.hadRecentInput) {
console.log('shift', entry.value.toFixed(4),
entry.sources.map((s) => s.node)) // node points at the reflowed block
}
}
}).observe({ type: 'layout-shift', buffered: true })
| Metric | Target | How measured |
|---|---|---|
| Font-swap CLS | 0.00 | layout-shift entries in swap window |
| Page CLS | < 0.1 | Web Vitals overlay / field RUM |
| Fallback visible window | < 300ms | filmstrip between FCP and fonts-ready |
If a shift still fires after applying overrides, the percentages are off for the specific fallback — recompute them against the actual local() font the user’s OS provides, since the same CSS family resolves to different metrics across platforms. Walk the recorded entry.sources in the Layout Instability API output to confirm the reflowed node is the text block and not an unrelated element.
Frequently Asked Questions
Do metric overrides remove the need for font-display: swap?
No. The two solve different problems. font-display: swap controls when fallback text is shown versus hidden; metric overrides control whether the eventual swap moves pixels. Keep swap (or optional) for visibility timing and add a metric-matched @font-face so the swap contributes 0.00 to CLS.
Why does the fallback still shift on some devices after I set the overrides?
Because local('Arial') resolves to different metrics across platforms — the Arial on Windows is not metrically identical to the substitute used on Linux or Android. Recompute the percentages against the actual local() font each OS provides, or pin a specific fallback whose metrics you control, then re-verify with a layout-shift observer.
Does preloading the font reduce Cumulative Layout Shift on its own?
Not directly. Preloading shrinks the window during which fallback text is visible, which reduces the chance a user sees the swap, but the swap itself still reflows unless the fallback box matches the web font box. Use preload to shorten the window and metric overrides to make any swap free.
Which tool should I use to generate the matched font-face block?
Any of them produce the same output. Next.js next/font bakes it in automatically, Fontaine wraps it into a build plugin, and Capsize exposes the raw numbers if you want to hand-tune. Pick whichever fits your build; the resulting descriptor set is what matters, not the tool.
What is line-gap-override and why is it almost always 0%?
line-gap-override sets the external leading a font requests above and below each line box. Most web fonts declare a line gap of zero and express their spacing through ascent and descent instead, so setting the fallback’s line-gap-override to 0% strips any extra leading the system fallback would otherwise add and keeps the two line boxes identical.
Related Guides
- Font Loading and Text Rendering — the parent guide covering how fonts fetch, decode, and paint in the pipeline.
- Preventing FOUT and FOIT with font-display — choose the swap-timing policy that pairs with these metric overrides.
- Debugging CLS with the Layout Instability API — trace which node reflowed when a shift still fires.
- Optimizing critical CSS for faster first paint — the same get-it-early strategy applied to render-blocking CSS.