Calculating size-adjust for a system-ui Fallback

This deep-dive is part of the Fallback Font Stack Design guide, which itself sits inside the Typography Fundamentals & System Architecture blueprint.

Problem statement

When a web font swaps in over a system-ui fallback, the two faces almost never share the same per-character advance width or x-height. The fallback paints first, the browser reflows every wrapped line when the real font arrives, and that reflow registers as Cumulative Layout Shift (CLS). The size-adjust descriptor on the fallback @font-face scales the fallback's glyphs by a single percentage so its average advance width — and therefore its line-wrap geometry — matches the web font before the swap even happens. Get the percentage right and the swap is invisible: zero reflow, CLS contribution ≈ 0. Get it wrong and you simply move the shift instead of removing it. This page shows how to derive that one number from font metrics.

Prerequisites

  • Your web font is self-hosted as WOFF2 and you can read its binary (or have it locally) to extract metrics.
  • You have already chosen a concrete fallback face — system-ui resolves to a known font on the target platform (typically San Francisco on macOS/iOS, Segoe UI on Windows, Roboto on Android), so you must pick one reference fallback to measure against. Arial is a safe cross-platform proxy.
  • Node 18+ with fontkit installed (npm i fontkit) for metric extraction, or fonttools on the CLI.
  • A baseline CLS number from field or lab data so you can prove the override worked. The CWV target is CLS < 0.1; metric-matched fallbacks routinely take font-swap CLS to 0.00.

The ratio that matters

size-adjust rescales the fallback so that, at any given font-size, the fallback's glyphs occupy the same horizontal space as the web font. Because line breaking is driven by advance widths, matching the average advance width is what removes reflow. The formula is:

size-adjust = (web font average advance width ÷ fallback average advance width) × 100%

Two practical ways to obtain the "average advance":

  • Weighted average advance over a representative text sample (best accuracy). Sum each glyph's advance weighted by how often that character appears in real copy, then normalize by unitsPerEm.
  • x-height ratio as a fast proxy: (webFont.xHeight / webFont.unitsPerEm) ÷ (fallback.xHeight / fallback.unitsPerEm). x-height tracks perceived size closely, so this lands within ~1–2% of the advance-based number for most Latin text and is what the Malte Ubl / fontaine-style automated tooling uses when it generates fallback faces for you.

This is the same metric thinking covered in Understanding x-height differences in web fonts — the x-height divergence between two faces is precisely what size-adjust neutralizes.

Deriving size-adjust from average advance width A web font and a fallback font are measured for average advance, the ratio becomes the size-adjust percentage, and the corrected fallback matches the web font line width. Web font avg advance 0.512 em system-ui fallback avg advance 0.476 em size-adjust = 0.512 / 0.476 = 107.6% Corrected fallback wraps identically to web font Same number rescales x-height too, so swap reflow approaches zero.
One ratio — web-font advance over fallback advance — becomes the size-adjust percentage that aligns line geometry.

Implementation

Compute the ratio from font metrics (Node + fontkit)

import fontkit from 'fontkit';

// Average advance over a representative character sample, normalized to em.
function avgAdvance(font, sample) {
  const upem = font.unitsPerEm;
  let total = 0;
  for (const ch of sample) {
    const glyph = font.glyphForCodePoint(ch.codePointAt(0));
    total += glyph.advanceWidth / upem;   // advance in em units
  }
  return total / sample.length;
}

// Frequency-weighted English sample is more accurate than the alphabet alone.
const SAMPLE = 'etaoinshrdlcumwfgypbvkjxqz ETAOINSHRDLU';

const web = fontkit.openSync('./fonts/Inter-Regular.woff2');
const fallback = fontkit.openSync('./fonts/Arial.ttf'); // local proxy for system-ui

const sizeAdjust = (avgAdvance(web, SAMPLE) / avgAdvance(fallback, SAMPLE)) * 100;
console.log(`size-adjust: ${sizeAdjust.toFixed(1)}%`);

// Bonus: the vertical overrides come straight from the web font's own metrics.
const upem = web.unitsPerEm;
console.log(`ascent-override:  ${(web.ascent  / upem * 100).toFixed(1)}%`);
console.log(`descent-override: ${(Math.abs(web.descent) / upem * 100).toFixed(1)}%`);
console.log(`line-gap-override:${(web.lineGap / upem * 100).toFixed(1)}%`);

Run it once per font pair and paste the four numbers it prints into the fallback @font-face below.

Metric-matched system-ui fallback

/* Fallback face: an alias over the platform system font with metrics
   forced to mirror Inter so wrapping and line height match before swap. */
@font-face {
  font-family: 'Inter Fallback';
  src: local('Arial'), local('Helvetica Neue'); /* resolves to system-ui-ish faces */

  /* Horizontal: (Inter avg advance 0.512em / Arial avg advance 0.476em) * 100 */
  size-adjust: 107.6%;

  /* Vertical: Inter hhea/OS-2 metrics ÷ unitsPerEm (2048), as % of em.
     These pin the line-box height so the swap doesn't grow/shrink lines. */
  ascent-override: 96.9%;   /* 1984 / 2048 */
  descent-override: 24.1%;  /* 494  / 2048 */
  line-gap-override: 0%;    /* 0    / 2048 */
}

:root {
  /* Real font first, metric-matched fallback second, raw system-ui last. */
  --font-body: 'Inter', 'Inter Fallback', system-ui, sans-serif;
}

body { font-family: var(--font-body); }

Why each number exists:

  • size-adjust: 107.6% — Arial's glyphs are narrower than Inter's, so the fallback must be scaled up 7.6% to occupy the same horizontal run length. This is the line-wrap fix and the single most important value.
  • ascent-override / descent-override — derived from the web font's hhea/OS/2 ascent and descent ÷ unitsPerEm. Because size-adjust scales the fallback's own metrics, you override the verticals to the web font's values so the line box is identical height in both states. These pair with the deeper treatment in ascent-override and descent-override to reduce CLS.
  • line-gap-override: 0% — most web UI fonts ship a zero line gap; force it explicitly so the fallback doesn't inherit the platform font's gap and add vertical drift.

Choosing the right reference fallback

The single biggest source of error is measuring against the wrong fallback. system-ui is a keyword, not a face: it resolves to San Francisco on macOS/iOS, Segoe UI on Windows, and Roboto on Android. Arial is the conventional cross-platform proxy because it is metrically close to those system sans faces and is almost universally installed, but the advance-width gap between Arial and, say, Inter is not identical to the gap between Segoe UI and Inter. For a first pass, measure against Arial and ship one override. If your RUM platform split is lopsided — most traffic on one OS — measure against that platform's real system face instead, since closing the gap for the majority of users matters more than a theoretical average. The category must also match: never compute a size-adjust for a serif web font against a sans-serif fallback, because their advance distributions diverge enough that no single scale aligns them.

Defensive variant

local() can resolve to an unexpected face (a user may have a different Arial, or none), and an over-aggressive size-adjust can itself introduce shift if your measured sample didn't match real copy. Guard with a feature query and a conservative, rounded value, and only apply the override when the descriptors are actually supported.

Defensive fallback with feature guard

/* Only build the override face where size-adjust is honored. */
@supports (size-adjust: 100%) {
  @font-face {
    font-family: 'Inter Fallback';
    src: local('Arial');
    size-adjust: 107%;       /* rounded to 1 sig-fig of safety margin */
    ascent-override: 96.9%;
    descent-override: 24.1%;
    line-gap-override: 0%;
  }
  :root { --font-body: 'Inter', 'Inter Fallback', system-ui, sans-serif; }
}

/* Browsers without size-adjust skip the override face entirely and use
   the plain stack — no broken alias, just an un-optimized (but correct) swap. */
@supports not (size-adjust: 100%) {
  :root { --font-body: 'Inter', system-ui, sans-serif; }
}

Clamp the computed value if your tooling produces an outlier — anything below 85% or above 130% almost always signals a mismatched unitsPerEm or a fallback that is the wrong category (serif measured against sans). Recheck the extraction before shipping a wild number.

Verification

  1. Record a baseline. In Chrome DevTools, open Performance, enable Web Vitals, throttle to Slow 4G, and reload. Note the CLS value and the layout-shift entries attributed to the text container before adding the fallback face.
  2. Add the metric-matched @font-face, reload under the same throttle, and confirm the font-swap layout-shift entries disappear from the Experience track.
  3. Programmatically watch for shifts so the check is repeatable:
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) {
      console.log('layout-shift', entry.value.toFixed(4), entry.sources);
    }
  }
}).observe({ type: 'layout-shift', buffered: true });

A correctly tuned size-adjust drives the swap-related entries to 0.0000. If a residual shift remains, it is vertical (line-box height), not horizontal — revisit the ascent/descent overrides.

Common pitfalls

  • Measuring the wrong fallback. system-ui is San Francisco on macOS but Segoe UI on Windows; a size-adjust tuned to Arial may be off by 2–4% on another platform. Pick the dominant platform in your RUM data, or ship per-platform overrides.
  • Forgetting unitsPerEm normalization. Raw advance values are in design units (often 1000, 2048, or 1024). Comparing un-normalized advances between fonts with different unitsPerEm yields a nonsense ratio.
  • Using the alphabet instead of weighted text. A flat A–Z average over-weights wide rare letters (W, M) and skews the ratio; a frequency-weighted sample matches real wrapping behavior.
  • Setting size-adjust on the web font instead of the fallback. The override belongs on the fallback @font-face only — applying it to the real font permanently shrinks or enlarges your actual type.
  • Skipping the vertical overrides. size-adjust fixes horizontal reflow but not line-box height; without ascent-override/descent-override you trade a horizontal shift for a vertical one.

FAQ

Does size-adjust change how my real web font looks? No — when it lives on the fallback @font-face it only rescales the fallback face shown before the swap. Your actual font renders at its true size. Never put size-adjust on the primary font unless you intend to permanently resize it.

Can I let a tool generate these numbers instead of computing them by hand? Yes. fontaine (and Next.js's built-in font optimization, which uses the same Malte Ubl approach) reads the web font's metrics and emits a metric-matched fallback @font-face with size-adjust, ascent-override, and descent-override automatically. Computing it yourself is worth it when you self-host and want to audit or override the exact values.

Why is my computed size-adjust above 110% — is that wrong? Not necessarily. Condensed or narrow web fonts legitimately need a large upward scale to match a wider fallback. Values become suspect only past ~130%, which usually means a unitsPerEm normalization bug or a serif/sans category mismatch.

Related