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-face already carries a chosen font-display value (swap for body, optional for hero text — see font-display: swap vs optional for the tradeoff).
  • You can read the web font's OS/2 and hhea tables to extract sCapHeight, sxHeight, sTypoAscender, sTypoDescender, and unitsPerEm. fonttools provides 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 so local() 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 sCapHeight can shift slightly across the wght axis.

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
Fallback stack paint order Layered stack showing Inter as the primary web font on top, FallbackSans metric-matched below it, then system-ui, then sans-serif as the last resort generic. Fallback stack paint order Inter (web font) loads async FallbackSans size-adjust + overrides system-ui unadjusted, varies by OS sans-serif last resort
The metric-matched face must sit ahead of system-ui and any generic so it paints during the swap window.

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 COLR layers — neither has a meaningful sCapHeight relationship to a Latin fallback, so size-adjust computed 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-family declaration as body text inherits whatever size-adjust you set for prose, which can shrink or enlarge icon glyphs unpredictably on swap. Keep icon fonts in an isolated font-family with font-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 opsz axis, 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.
CLS: unmatched vs matched fallback Bar chart comparing Cumulative Layout Shift score for a raw sans-serif fallback against a metric-matched FallbackSans, with the matched bar much shorter and highlighted. CLS: unmatched vs matched fallback raw sans-serif 0.18 CLS metric-matched 0.01 CLS CLS score
A metric-matched fallback removes essentially all layout shift attributed to the font swap.

Verification

  1. In DevTools → Network, throttle to Slow 3G to widen the swap window, then reload.
  2. Open Performance, record the load, and inspect Main thread → Layout Shift events. Confirm no shift is attributed to the font-family swap — target CLS contribution of 0 from the font.
  3. Run Rendering → Layout Shift Regions and watch the text block during swap; the highlighted shift region should not flash over the body copy.
  4. 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.
  5. 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.
  6. In production, attach a PerformanceObserver for layout-shift entries or wire up web-vitals's onCLS callback and log the sources array; a source whose node is 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.
CLS against the 0.1 budget Meter showing measured layout shift of 0.01 against a budget threshold of 0.1, well within range. CLS against the 0.1 budget 0 0.25 0.01 measured 0.1 CWV budget
After metric alignment the font's CLS contribution sits far under the Core Web Vitals threshold.

Common Pitfalls

  • Generic sans-serif fallback with no size-adjust. Relying on raw sans-serif guarantees 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, usually 0%.
  • font-display: block without a preload. Text stays invisible for up to 3s, which is far more disruptive to screen-reader users than a well-managed swap. Use swap plus metric overrides instead.
  • font-synthesis: weight on the fallback. It triggers OS-level faux-bold that thickens stems and misaligns the baseline, defeating the override math. Set font-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-ui before the matched face. If the metric-aligned 'FallbackSans' is listed after system-ui in 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 that system-ui itself 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: 400 can drift a few percent at wght: 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-adjust on 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