Performance Cwv
The Layout Shift You Forgot (Fonts)
Reading level
The headline that jumps
You build a landing page. The headline looks perfect. In Lighthouse, your CLS score is 0.28 — terrible. You can't figure out why until you watch the slow-motion filmstrip: the headline loads in system font first, then jumps when the custom font arrives. The text size is different, the layout reflows, and everything below shifts down. That's font-induced CLS.
Font-induced Cumulative Layout Shift is caused by the Flash Of Unstyled Text (FOUT) — the brief period where the browser renders text in a fallback font before the custom font loads. If the fallback and custom fonts have different metrics (size, line-height, x-height), text reflows, content shifts, and CLS accumulates.
CLS from fonts is measurable in CrUX as "layout shift with source: text." It's disproportionate for the top-of-page hero and heading elements — larger text = larger shift area = higher CLS impact. The fix set: font-display: optional (for non-critical fonts), size-adjust + ascent-override + descent-override for metric-matched fallbacks, and preload for critical fonts.
font-display modes and their trade-offs
- auto — browser decides (usually same as block)
- block — hide text until font loads (invisible text = bad for UX)
- swap — show fallback immediately, swap when loaded (causes FOUT and CLS)
- fallback — short block period (100ms), then swap if not loaded (best of both)
- optional — very short block (100ms), skip if not fast enough (no CLS guarantee)
font-display: swap is often recommended but causes CLS. font-display: optional prevents CLS by not swapping at all if the font is slow — but users may see fallback font more often.
The modern fix for CLS without sacrificing the custom font: metric-matched fallback using @font-face overrides:
@font-face {
font-family: 'Inter-Fallback';
src: local('Arial');
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
body {
font-family: 'Inter', 'Inter-Fallback', sans-serif;
}
When Inter loads and swaps in, the metrics match — text doesn't reflow, CLS stays near zero.
Tools to calculate metric-match values: screenspan.net/fallback auto-computes the override values for any Google Font. For self-hosted fonts, fontpie CLI generates the @font-face override block. Target: visual difference between fallback and custom font should be imperceptible, so layout shift — even if it occurs — is below the CLS threshold of 0.1.
The price that moved at the worst moment
It was Black Friday. The e-commerce team had just launched a product page with a gorgeous new brand font. Traffic was high. Then support tickets started coming in: "I clicked Add to Cart but got the wrong item." After an hour of investigation, they found it on video — the price and button loaded in system font first, then the brand font arrived a half-second later. Everything shifted down 40 pixels. Users who clicked "Add to Cart" during that shift hit "Add to Wishlist" instead. Real orders. Real confusion. CLS score: 0.35.
The technical cause: the brand font was loaded with font-display: swap — meaning the browser showed system font immediately, then swapped to the brand font when it finished downloading. The brand font had a taller x-height than Arial. When it swapped in, the price element grew by 18px, pushing the buttons below the fold momentarily. The Lighthouse CLS score of 0.35 was caused entirely by this single font swap event on the above-the-fold content block.
In CrUX data, this page had a 65th-percentile CLS of 0.28 — "needs improvement" territory. Chrome 108+ CLS attribution (via PerformanceObserver with sources array) pinpointed the layout shift element as the price container with shift fraction 0.7. The shift was correlated with first-contentful-paint timing on 3G connections — users on slower connections experienced it on every visit.
Matching the fallback so the swap is invisible
The fix wasn't to remove the custom font — that would hurt brand identity. Instead, the team made the fallback font look identical to the brand font in terms of size. When the brand font swapped in, there was nothing to reflow because the dimensions were already the same. CLS dropped from 0.35 to 0.02. The support tickets stopped.
They used the metric-matched fallback technique: a synthetic @font-face declaration for "BrandFont-Fallback" that loaded local('Arial') but applied size-adjust, ascent-override, and descent-override to match the brand font's exact metrics. Values were generated from screenspan.net/fallback. They also added <link rel="preload"> for the font file to cut the swap time from 800ms to under 200ms on 3G.
@font-face {
font-family: 'BrandFont-Fallback';
src: local('Arial');
size-adjust: 104%;
ascent-override: 88%;
descent-override: 20%;
line-gap-override: 0%;
}
body {
font-family: 'BrandFont', 'BrandFont-Fallback', sans-serif;
}
Post-fix CrUX measurement showed 75th-percentile CLS at 0.04 — "good." They added font metric matching to the design system's font token spec: every new typeface must ship with a calibrated fallback @font-face block before going to production. Lighthouse CI gates on CLS < 0.1. The Black Friday incident became the internal case study that justified the tooling investment.
Pattern at a glance
Annotated example: font loading with and without metric-matched fallback
❌ FONT SWAP WITHOUT METRIC MATCH
/* @font-face — bare swap */
@font-face {
font-family: 'BrandFont';
src: url('/fonts/brand.woff2');
font-display: swap;
}
body {
font-family: 'BrandFont', Arial, sans-serif;
}
/* Result: Arial renders at ~14px line-height,
BrandFont renders at ~18px — layout shifts
on swap, CLS = 0.35 */
Different fallback metrics — text reflows on swap
✅ METRIC-MATCHED FALLBACK
/* Synthetic fallback matching BrandFont metrics */
@font-face {
font-family: 'BrandFont-Fallback';
src: local('Arial');
size-adjust: 104%;
ascent-override: 88%;
descent-override: 20%;
line-gap-override: 0%;
}
body {
font-family: 'BrandFont',
'BrandFont-Fallback',
sans-serif;
}
/* Result: fallback occupies same space as
BrandFont — swap is invisible, CLS = 0.02 */
Matched metrics — swap is imperceptible
Try it: font swap with and without metric match
Watch the headline in "Broken" mode — it jumps when the font arrives. In "Fixed" mode the same swap happens but the text doesn't shift because the fallback metrics match.
The CLS difference is dramatic: broken mode typically scores 0.2–0.4 in Lighthouse; fixed mode with metric-matched fallback scores <0.05.
Run Lighthouse in throttled mode with both versions — the filmstrip will show the FOUT moment clearly. The fixed version's filmstrip shows no visible difference at the swap frame.
Showing: Fixed — metric-matched fallback
Eliminating font-induced CLS: the full toolkit
Step 1: preload the font. Step 2: match the fallback metrics. These two changes handle 90% of font CLS.
<!-- In <head>: preload critical font file -->
<link rel="preload" href="/fonts/brand.woff2"
as="font" type="font/woff2" crossorigin>
/* In CSS: metric-matched fallback */
@font-face {
font-family: 'Brand-Fallback';
src: local('Arial');
size-adjust: 104%; /* from screenspan.net/fallback */
ascent-override: 88%;
descent-override: 20%;
line-gap-override: 0%;
}
body {
font-family: 'BrandFont', 'Brand-Fallback', sans-serif;
}
Key pitfall: crossorigin is required on the preload even for same-origin fonts — without it, the browser downloads the font twice (once for preload, once for the @font-face rule).
font-display value tradeoffs — pick based on how critical the font is:
- swap — show fallback immediately, swap when loaded. Causes FOUT + CLS unless you use metric-matched fallback
- fallback — 100ms block period (invisible text), then swap if loaded fast enough, else keep fallback. Best middle ground
- optional — 100ms block, then skip if not loaded. Zero CLS guaranteed. Font may never render on slow connections
- block — hide text until font loads. Never use for body text — invisible content harms LCP and UX
For non-critical decorative fonts (logos, display headings), font-display: optional is the safest choice. For body text where the custom font must render, use swap + metric-matched fallback.
Tools: screenspan.net/fallback for Google Fonts values; fontpie CLI for self-hosted fonts. In Chrome DevTools, Rendering panel has "Emulate CSS media type" — switch to "prefers-reduced-data" to simulate font-blocked state.
CLS attribution in Chrome 108+: use PerformanceObserver with the sources array to identify the exact element causing shift:
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
for (const source of entry.sources) {
console.log('CLS source:', source.node, 'shift:', entry.value);
}
}
}
}).observe({ type: 'layout-shift', buffered: true });
At design system scale: encode metric-override values as design tokens. Ship a fontpie-generated fallback @font-face block alongside every typeface token. Gate font merges on a Lighthouse CI check with --budget-path enforcing CLS < 0.1. In Next.js 13+, next/font generates metric-matched fallbacks automatically — it is the zero-config solution for Next.js apps. For non-Next apps, the manual approach above applies.
References
Remember
Key takeaways
-
font-display: swap prevents invisible text but causes layout shift when the custom font arrives — it trades one bad UX for another.Metric-matched fallback fonts (size-adjust, ascent-override, descent-override) make the FOUT invisible by matching the fallback dimensions to the custom font.Use screenspan.net/fallback or fontpie CLI to generate metric-override values — don't hand-tune them, the math is precise and tooling is reliable.
-
Preload critical fonts with <link rel="preload"> — this shortens the time to swap so fewer users see the fallback font at all.font-display: optional is the CLS-safe choice for non-critical fonts — it never swaps after the initial short block period, guaranteeing zero CLS.Measure font-induced CLS in CrUX with source attribution — Chrome 108+ labels CLS shift sources, making font-vs-image-vs-other breakdown visible in real user data.
Keep going
Finish this takeaway, then continue the track — Casey saved your spot locally.
Sign in with email to sync progress across devices (beta).
Inside the Casebook
New cases every few weeks — patterns from production UI engineering. Double opt-in, easy unsubscribe.
No spam. Unsubscribe anytime. Emails sent via Buttondown.
RSS feed