Reducing Layout Shift from Web Fonts

When a web font swaps in, the fallback and the real font almost never share the same x-height and advance widths, so every text block reflows the instant the swap fires — a layout shift the browser records as Cumulative Layout Shift. This page shows how size-adjust, ascent-override, descent-override, and line-gap-override reshape the fallback to match the web font’s box so the swap moves no pixels, plus the f-mods technique and preloading. It builds on Font Loading and Text Rendering and is part of Browser Rendering Pipeline Fundamentals.

The Metric Mismatch

A font’s metrics — units-per-em, ascent, descent, line gap, and per-glyph advance widths — determine exactly how much vertical and horizontal space a run of text occupies. The fallback (Arial, Times) has different metrics than your web font, so text laid out in the fallback wraps and stacks differently. When font-display: swap replaces the fallback, the browser re-runs layout with the new metrics and the block changes height, shoving everything below it.

/* Reproduction: swap with no metric matching — guaranteed reflow */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter.woff2') format('woff2');
  font-display: swap;          /* fallback shown first, then swaps to Inter */
}
body { font-family: 'Inter', Arial, sans-serif; }  /* Arial box ≠ Inter box */

Arial’s x-height and advances are narrower than Inter’s, so a paragraph that wrapped to 12 lines in Arial may wrap to 11 in Inter. The block shrinks, content below jumps up, and the layout instability algorithm scores the movement.

[Main Thread — Inter swap on an article body]
 0ms        | FCP — Arial fallback, article height = 2140px
 940ms      | Inter woff2 decoded, render-tree font re-match
 942–953ms  | Layout (11ms) — article reflows to 2068px
 953ms      | Paint — 72px upward shift of footer, CLS += 0.09

Metric Overrides

The fix is to declare a second @font-face that aliases the local fallback font but overrides its metrics so it occupies the web font’s exact box. Four descriptors do this, all on the @font-face rule:

  • size-adjust scales every glyph uniformly so the fallback’s x-height matches the web font’s.
  • ascent-override pins the ascent (space above the baseline) used for line-box height.
  • descent-override pins the descent (space below the baseline).
  • line-gap-override sets the extra leading, almost always to 0%.
/* Fix: a metric-matched fallback that occupies Inter's exact box */
@font-face {
  font-family: 'Inter';            /* the real web font */
  src: url('/fonts/inter.woff2') format('woff2');
  font-display: swap;
}

@font-face {
  font-family: 'Inter Fallback';   /* aliases a local system font */
  src: local('Arial');
  size-adjust: 107%;       /* scale Arial up so x-heights align */
  ascent-override: 90%;    /* match Inter's ascent → identical line box */
  descent-override: 22%;   /* match Inter's descent */
  line-gap-override: 0%;   /* strip Arial's extra leading */
}

/* Order matters: Inter first, the matched fallback second */
body { font-family: 'Inter', 'Inter Fallback', sans-serif; }

With the overrides applied, the fallback renders in a box dimensionally identical to Inter. When Inter swaps in, glyph shapes change but the box size does not, so layout produces zero displacement and the swap contributes 0.00 to CLS.

The f-mods Technique

The override percentages are not guesses — they are computed from the two fonts’ raw metric tables. The “f-mods” (font modifiers) technique reads ascent, descent, lineGap, and unitsPerEm from both fonts and derives the percentages so the fallback’s line box exactly equals the web font’s:

// Derive override percentages from raw font metrics (build-time)
function fMods(web, fallback) {
  const webRatio = web.unitsPerEm
  return {
    // size-adjust aligns x-heights between the two faces
    sizeAdjust: `${((web.xHeight / web.unitsPerEm) /
                    (fallback.xHeight / fallback.unitsPerEm) * 100).toFixed(2)}%`,
    ascentOverride: `${(web.ascent / webRatio * 100).toFixed(2)}%`,
    descentOverride: `${(Math.abs(web.descent) / webRatio * 100).toFixed(2)}%`,
    lineGapOverride: `${(web.lineGap / webRatio * 100).toFixed(2)}%`,
  }
}

Tooling automates exactly this: Next.js next/font, Fontaine, and the Capsize library parse the metric tables and emit the matched @font-face block, so you rarely hand-author the numbers. The mechanism is identical regardless of tool — a fallback whose line box equals the web font’s.

Preload to Shorten the Swap Window

Metric overrides eliminate the shift; preloading shrinks the window during which fallback text is visible at all. Starting the fetch during HTML parse rather than after render-tree matching often lets the web font arrive before or near FCP, so under optional it can be used immediately and under swap the fallback flash is brief.

<!-- crossorigin is required: font fetches are always CORS-mode -->
<link rel="preload" href="/fonts/inter.woff2" as="font"
      type="font/woff2" crossorigin>

Combine the two: preload narrows the visible fallback period, and the metric-matched fallback guarantees that whenever the swap does happen, nothing moves. This is the same strategy that keeps CSS off the critical path in Optimizing critical CSS for faster first paint — get the resource early, and make its arrival cost nothing.

Verification

Confirm the swap moves zero pixels with a layout-shift observer scoped to the load window:

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // hadRecentInput filters user-driven shifts; only unexpected ones count to CLS
    if (!entry.hadRecentInput) {
      console.log('shift', entry.value.toFixed(4),
        entry.sources.map((s) => s.node))   // node points at the reflowed block
    }
  }
}).observe({ type: 'layout-shift', buffered: true })
  • No layout-shift entry fires in the window between FCP and document.fonts.ready
  • DevTools → Rendering → Layout Shift Regions
Metric Target How measured
Font-swap CLS 0.00 layout-shift entries in swap window
Page CLS < 0.1 Web Vitals overlay / field RUM
Fallback visible window < 300ms filmstrip between FCP and fonts-ready

If a shift still fires after applying overrides, the percentages are off for the specific fallback — recompute them against the actual local() font the user’s OS provides, since the same CSS family resolves to different metrics across platforms. Walk the recorded entry.sources in the Layout Instability API output to confirm the reflowed node is the text block and not an unrelated element.