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.

Overlaid line boxes of primary and fallback fontsCap-height and baseline of a fallback font realigned to the primary web font by size-adjust and ascent-override so the swap leaves the baseline fixed.shared baseline (locked)primary web fontHgcap-heightmatched fallbackHgsize-adjust + ascent-override
Override descriptors realign the fallback's cap-height and baseline to the primary font, so swapping faces does not move the baseline.

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).
  • 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, 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.

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

  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.
  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.

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.

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