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 area. 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. The same technique is what underlies ascent-override and descent-override to reduce CLS and it composes directly with whatever font-display value you have chosen for the primary face.
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 — see font-display: swap vs optional for the tradeoff). - 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, Georgia, 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.
- If the primary face is a variable font, decide which static instance's metrics you are matching against, since
sCapHeightcan shift slightly across thewghtaxis.
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 |
Worked Example: A Serif Body Font Over Georgia
Sans-serif matching gets most of the attention, but serif body text is at least as prone to CLS because serif fallbacks like Georgia and Times New Roman carry unusually large x-heights relative to their cap-height. Suppose the primary face is a serif editorial font with sCapHeight = 1409 and unitsPerEm = 2048 (cap-height ratio 0.688), while Georgia reports sCapHeight = 1467 at unitsPerEm = 2048 (ratio 0.716). Rather than eyeballing the difference, run the ratio arithmetic explicitly:
Computing the override percentages for a serif pair
python3 - <<'PY'
primary_cap = 1409 / 2048
fallback_cap = 1467 / 2048
size_adjust = primary_cap / fallback_cap * 100
print(f"size-adjust: {size_adjust:.1f}%")
# ascent/descent are then divided by the size-adjust ratio to compensate
# for the scale already applied by size-adjust itself
primary_ascent = 1950 / 2048
size_adjust_ratio = size_adjust / 100
print(f"ascent-override: {(primary_ascent / size_adjust_ratio) * 100:.1f}%")
PY
The critical detail here is the second print: because size-adjust already scales the glyph outlines, the ascent and descent percentages must be divided by that same ratio before being expressed as overrides, or the line box ends up taller than the primary font's own line box even though the glyphs are correctly sized. Skipping this division is the single most common arithmetic mistake in metric-matched fallbacks, and it produces a stack that looks right on inspection but still measures nonzero CLS in the field. Apply the computed values the same way as the sans-serif example:
@font-face {
font-family: 'FallbackSerif';
src: local('Georgia');
size-adjust: 96.2%;
ascent-override: 99.0%;
descent-override: 24.1%;
line-gap-override: 0%;
}
:root {
--font-serif: 'EditorialSerif', 'FallbackSerif', Georgia, serif;
}
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. If the design spans multiple weights, remember that a single fallback override was tuned against one weight's metrics — see loading variable fonts with font-weight ranges for how heavier weights can widen glyphs enough to need a slightly different size-adjust at the extremes, and clamping variable font weight for accessibility if you are also constraining the wght axis for users with low-vision settings.
Edge Cases: Non-Latin Scripts, Icon Fonts, and Multi-Script Pages
Metric overrides are computed per font-family, which breaks down the moment a page mixes scripts. A few situations deserve explicit handling:
- CJK and emoji fallbacks. CJK glyphs are near-square and emoji glyphs are colored bitmaps or
COLRlayers — neither has a meaningfulsCapHeightrelationship to a Latin fallback, sosize-adjustcomputed from Latin metrics will visibly mis-scale ideographs. Treat CJK and emoji as separate stack segments with their own fallback logic rather than folding them into the Latin override; see fallback stacks for CJK and emoji fonts for the segment-by-segment approach. - Icon fonts. An icon font glued into the same
font-familydeclaration as body text inherits whateversize-adjustyou set for prose, which can shrink or enlarge icon glyphs unpredictably on swap. Keep icon fonts in an isolatedfont-familywithfont-display: block(icons are usually small enough that a short invisible window is acceptable) rather than routing them through the prose fallback chain; if you are seeing icon-specific jumps already, fixing layout shift from icon fonts walks through the isolation pattern in more depth. - RTL and mixed-direction text. Arabic and Hebrew fallbacks (Tahoma, Arial for Arabic contexts) have their own cap-height and ascent relationships that do not track Latin ratios; compute a second, independent override block for the RTL segment of the stack rather than reusing the Latin numbers, and verify with
dir="rtl"test fixtures at 200% zoom. - Optical sizing. If the primary face uses an
opszaxis, a fallback tuned against the display-size instance will be mismatched when the page renders body-size text, since cap-height can shift a few percent across the optical range — see opsz: auto vs manual optical sizing for how to pick the instance you measure against.
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 — automate this check with the pattern in automating font budget checks with Lighthouse CI so a regression fails the build instead of shipping.
- 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.
- In production, attach a
PerformanceObserverforlayout-shiftentries or wire upweb-vitals'sonCLScallback and log thesourcesarray; a source whosenodeis a text container that swapped fonts is your confirmation the fix held under real network conditions, not just the throttled lab test — see tracking font CLS with the web-vitals library.
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. - Forgetting to re-derive overrides after a variable weight change. An override computed against
wght: 400can drift a few percent atwght: 700, especially on fonts with pronounced weight-dependent set width; re-check the ratio whenever the default weight in the design changes. - Applying one Latin override to a mixed-script page. As covered above, CJK, emoji, and RTL segments need their own override math; reusing the Latin
size-adjuston non-Latin runs of text produces visibly wrong glyph scale even though CLS may still measure at zero.
Frequently Asked Questions
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.
Do I need a separate override block for bold and italic weights?
Yes, if the design uses more than one static weight for the same family. size-adjust computed against the regular weight's cap-height can be a percent or two off at bold, because bolder cuts of a typeface are frequently drawn with slightly different proportions. For a variable primary font, compute the override against whichever static instance the fallback is most likely to be visible under — typically the default weight users see on first paint — and accept the small residual mismatch at other weights, since it is well below the threshold that registers as CLS.
Can I automate size-adjust calculation instead of doing it by hand per font pair?
Yes — wrap the ttx extraction and ratio arithmetic shown above into a small script that takes two font paths and prints the four descriptor values, then commit the generated @font-face block alongside the font files so it regenerates automatically whenever either font is upgraded. Treat the generated CSS as a build artifact, not something hand-edited, so a font update can never silently drift out of alignment with its fallback.
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.
- Fallback stacks for CJK and emoji fonts — segment-by-segment overrides for non-Latin scripts.
- Debugging font-related layout shift — confirm the override drove CLS to zero.