Designing Accessible Fallback Font Stacks: Metric Alignment & CLS Prevention
This guide is part of the Fallback Font Stack Design workflow within the Typography Fundamentals & System Architecture blueprint. Establish baseline alignment principles via the parent workflow before deploying the stack overrides below.
Problem Statement
When a web font loads asynchronously under font-display: swap, the browser paints fallback text first, then re-renders with the web font. If the fallback's metrics — cap-height, x-height, ascent, descent — differ from the web font, every line reflows on swap, registering Cumulative Layout Shift (CLS) and pushing you over the 0.1 target. The shift is not merely a performance number: text that jumps during reflow disrupts screen-reader focus order and can violate WCAG 2.2 AA reflow expectations at 200% zoom. The fix is to define a named fallback @font-face over an installed system font, then realign its metrics to the web font using size-adjust, ascent-override, descent-override, and line-gap-override so the swap leaves the baseline fixed.
Prerequisites
- The web font is served as WOFF2 with
Cache-Control: public, max-age=31536000, immutable, and each@font-facealready carries a chosen font-display value (swapfor body,optionalfor hero text). - You can read the web font's
OS/2andhheatables to extractsCapHeight,sxHeight,sTypoAscender,sTypoDescender, andunitsPerEm.fonttoolsprovides this offline. - A target system fallback is identified (Arial, Roboto, or
system-ui) whose metrics you will override. The fallback must be installed locally solocal()resolves without a network request. - DevTools network throttling is available to reproduce the swap window on a slow connection.
Implementation: Metric-Matched Fallback
Extract the raw metrics, then compute the descriptors. size-adjust scales the fallback glyphs so x-height and overall set width match; ascent-override and descent-override fix the line-box height so wrapping is identical. Start by reading both fonts' tables:
Extract metrics from the OS/2 and hhea tables
ttx -t OS/2 -t hhea inter.woff2
# sCapHeight, sxHeight, sTypoAscender, sTypoDescender, unitsPerEm
Compute size-adjust = (primary-cap-height / fallback-cap-height) * 100, then normalize ascent and descent against unitsPerEm and divide by the applied size-adjust to express them as percentages. Work in the font's own unitsPerEm space — Inter and Roboto use 1000 or 2048 units per em, and mixing the two scales produces a fallback that is visibly too tall or too short even though the arithmetic "looks" right. Apply the results to a named fallback face that the stack references ahead of the system generic:
Metric-matched fallback configuration
@font-face {
font-family: 'FallbackSans';
src: local('Arial');
size-adjust: 107.5%;
ascent-override: 95.2%;
descent-override: 28.4%;
line-gap-override: 0%;
}
:root {
--font-primary: 'Inter', 'FallbackSans', system-ui, sans-serif;
}
body {
font-family: var(--font-primary);
font-synthesis: none;
}
The src: local('Arial') line is what makes this work without a download — 'FallbackSans' is not a file, it is a re-skin of an installed system font carrying the four override descriptors. size-adjust: 107.5% scales Arial up so its cap-height matches Inter's; ascent-override and descent-override pin the line box so each line occupies the same vertical space the web font will, which is what holds the baseline still on swap. line-gap-override: 0% removes Arial's intrinsic gap so vertical rhythm does not jump. Listing 'FallbackSans' before system-ui in the stack means the metric-aligned face is what paints during the swap window, not raw Arial. font-synthesis: none blocks the OS from faux-bolding or faux-italicizing the fallback, which would thicken stems and shift the baseline. For the exact platform-UI math, see calculating size-adjust for system-ui fallback.
| Descriptor | Source field | Purpose |
|---|---|---|
size-adjust |
sCapHeight ratio |
Match cap/x-height and set width |
ascent-override |
sTypoAscender / unitsPerEm |
Lock the line box top |
descent-override |
sTypoDescender / unitsPerEm |
Lock the line box bottom |
line-gap-override |
lineGap |
Remove intrinsic leading jump |
Variable & Legacy Fallback Variant
Legacy engines ignore variable axes and select the font's default static weight, while feature support for the descriptors themselves varies. Guard the stack with a feature query so modern browsers get the variable face and older ones a static weight, and keep the metric-matched fallback in both branches:
Feature query for variable fallbacks
@supports (font-variation-settings: "wght" 1) {
:root { --font-stack: 'InterVariable', 'FallbackSans', sans-serif; }
}
@supports not (font-variation-settings: "wght" 1) {
:root { --font-stack: 'InterStatic', 'FallbackSans', system-ui, sans-serif; }
}
The @supports (font-variation-settings: "wght" 1) test resolves true only where the wght axis is drivable, so the variable file is offered exactly where it can interpolate; the not branch hands legacy browsers an explicit static weight rather than letting them snap a variable file to its default. Confirm the branch a browser takes from the Console with CSS.supports('font-variation-settings', "'wght' 1"). Because 'FallbackSans' appears in both arms, the realigned fallback paints during the swap regardless of which branch runs, so CLS stays flat across the whole support matrix. Preload the critical web font with <link rel="preload" as="font" type="font/woff2" crossorigin href="/fonts/inter-variable.woff2"> to shrink the window during which the fallback is visible at all.
Verification
- In DevTools → Network, throttle to Slow 3G to widen the swap window, then reload.
- Open Performance, record the load, and inspect Main thread → Layout Shift events. Confirm no shift is attributed to the
font-familyswap — target CLS contribution of 0 from the font. - Run Rendering → Layout Shift Regions and watch the text block during swap; the highlighted shift region should not flash over the body copy.
- Cross-check the Lighthouse "Avoid large layout shifts" audit and confirm the font swap no longer appears as a CLS contributor.
- For accessibility, run a reflow test at 320px width and 200% zoom: confirm no horizontal scrollbar, no clipped glyphs, and a maintained 4.5:1 contrast ratio on the fallback text.
Common Pitfalls
- Generic
sans-seriffallback with nosize-adjust. Relying on rawsans-serifguarantees a metric mismatch and a measurable CLS spike when the web font swaps in. Always interpose a metric-matched named face. - Ignoring
line-gap-override. Even with cap-height matched, an unmatched line gap re-flows leading on swap and can overlap adjacent text blocks. Set it explicitly, usually0%. font-display: blockwithout a preload. Text stays invisible for up to 3s, which is far more disruptive to screen-reader users than a well-managed swap. Useswapplus metric overrides instead.font-synthesis: weighton the fallback. It triggers OS-level faux-bold that thickens stems and misaligns the baseline, defeating the override math. Setfont-synthesis: none.- Skipping contrast and reflow tests at 200% zoom. A fallback that passes at 100% can fail WCAG 1.4.10 reflow or 1.4.3 contrast once magnified; test both Windows (DirectWrite) and macOS (CoreText) rendering.
- Putting
system-uibefore the matched face. If the metric-aligned'FallbackSans'is listed aftersystem-uiin the stack, the raw platform UI font paints during the swap window and the overrides never apply. Order the named fallback ahead of any generic, and remember thatsystem-uiitself resolves to a different physical font per OS, so its unadjusted metrics vary across platforms.
FAQ
How do I calculate the exact size-adjust percentage for a fallback font?
Extract sCapHeight from both fonts' OS/2 tables via fonttools, normalize each against unitsPerEm, then compute (primary-cap-height / fallback-cap-height) * 100 and apply it as size-adjust. Iterate in DevTools by comparing rendered line-heights with and without the override until the baseline stops moving on swap.
Does font-display: swap hurt accessibility?
Not when it is paired with metric overrides. swap renders text immediately and, with a metric-matched fallback, does so without the layout shift that disrupts screen-reader focus order. The alternative — font-display: block — hides text for up to 3s, which is the more harmful pattern for assistive-technology users.
Why do fallback fonts break vertical rhythm on high-DPI displays?
OS-level hinting and subpixel rendering position baselines differently per platform, so an override tuned on macOS can drift on Windows. Lock rhythm with line-gap-override: 0% and explicit unitless line-height, and validate on both Windows (GDI/DirectWrite) and macOS (CoreText), which apply different hinting strategies.
Related
- Fallback Font Stack Design — parent workflow for fallback ordering and weight mapping.
- Calculating size-adjust for system-ui fallback — the exact metric math for the platform UI face.
- Debugging font-related layout shift — confirm the override drove CLS to zero.