Using ascent-override and descent-override to Reduce CLS

This guide is part of the Font Metrics & Baseline Alignment topic inside the Typography Fundamentals & System Architecture blueprint.

Problem statement

A web font and its fallback usually disagree about how tall a line of text is. The browser computes line-box height from the font's vertical metrics — ascent, descent, and line gap — so when the real font swaps in with different metrics, every line grows or shrinks and the content below jumps. That jump is Cumulative Layout Shift (CLS), and size-adjust alone won't fix it because it scales glyphs horizontally, not the line box. The ascent-override, descent-override, and line-gap-override descriptors on the fallback @font-face force the fallback's vertical metrics to equal the web font's, so the line box is identical in both states and the swap moves nothing vertically.

Prerequisites

  • A self-hosted WOFF2 web font whose hhea and OS/2 tables you can read.
  • A named fallback @font-face aliasing a system font via local() (the override descriptors only work on a @font-face block, never on a plain font-family).
  • fontkit (npm i fontkit) or fonttools to extract metrics, plus unitsPerEm for the font.
  • A CLS baseline from lab or field data. The Core Web Vitals target is CLS < 0.1; a fully metric-matched fallback typically reaches 0.00 for font swap.

What the descriptors actually do

Each descriptor overrides one of the font's intrinsic vertical metrics, expressed as a percentage of the font size (i.e. of one em):

  • ascent-override — height above the baseline. Browsers normally read this from the hhea.ascent (or OS/2.sTypoAscender) value.
  • descent-override — depth below the baseline, from hhea.descent (OS/2.sTypoDescender), entered as a positive percentage.
  • line-gap-override — extra leading the font requests between lines, from hhea.lineGap (OS/2.sTypoLineGap).

To get the percentage for any of them: take the raw metric from the table and divide by the font's unitsPerEm, then multiply by 100. For a font with unitsPerEm = 2048 and hhea.ascent = 1984, the override is 1984 / 2048 × 100 = 96.9%. You copy the web font's numbers onto the fallback face — that is the whole trick. Pair these vertical overrides with the horizontal fix in calculating size-adjust for a system-ui fallback for a fallback that matches both line width and line height.

Matching fallback line-box height to the web font The fallback line box is taller than the web font line box, causing a downward shift on swap; ascent and descent overrides make the two boxes equal so nothing moves. Before override fallback box taller line web font box shift After override fallback box web font box equal height
Overriding the fallback's ascent and descent to the web font's values makes both line boxes the same height, so the swap produces no vertical shift.

Implementation

Extract the vertical overrides (Node + fontkit)

import fontkit from 'fontkit';

const font = fontkit.openSync('./fonts/Inter-Regular.woff2');
const upem = font.unitsPerEm; // e.g. 2048

const pct = (raw) => (Math.abs(raw) / upem * 100).toFixed(1) + '%';

console.log('ascent-override:  ', pct(font.ascent));   // hhea/OS-2 ascender
console.log('descent-override: ', pct(font.descent));  // abs() -> positive %
console.log('line-gap-override:', pct(font.lineGap));  // usually 0%

Run once per web font; the three printed values go straight into the fallback @font-face.

Metric-matched fallback @font-face

/* Fallback aliasing a system font, with the WEB font's vertical metrics
   forced onto it so the line box is identical before and after swap. */
@font-face {
  font-family: 'Inter Fallback';
  src: local('Arial'), local('Helvetica Neue');

  /* Horizontal match (see size-adjust deep-dive): Inter advance / Arial advance. */
  size-adjust: 107.6%;

  /* Vertical match — Inter's own hhea metrics ÷ unitsPerEm (2048): */
  ascent-override: 96.9%;    /* 1984 / 2048  -> height above baseline */
  descent-override: 24.1%;   /* 494  / 2048  -> depth below baseline  */
  line-gap-override: 0%;     /* 0    / 2048  -> Inter ships zero gap   */
}

:root {
  --font-body: 'Inter', 'Inter Fallback', system-ui, sans-serif;
}

body {
  font-family: var(--font-body);
  /* Keep line-height unitless so it scales with the matched metrics. */
  line-height: 1.5;
}

How each number was derived:

  • ascent-override: 96.9%Inter.hhea.ascent (1984) ÷ unitsPerEm (2048) × 100. Pins the space above the baseline to Inter's, not Arial's.
  • descent-override: 24.1%|Inter.hhea.descent (−494)| ÷ 2048 × 100. The metric is stored negative; the descriptor takes a positive percentage.
  • line-gap-override: 0% — Inter declares a zero line gap, so the fallback must too; leaving it unset lets the fallback inherit the system font's gap and reintroduces drift.
  • size-adjust: 107.6% — included because vertical matching alone leaves a horizontal reflow; the two fixes are complementary.

Reading the metrics on the CLI

If you prefer not to script fontkit, dump the same numbers with fonttools. The hhea table holds ascent, descent, and lineGap; the head table holds unitsPerEm:

ttx -t hhea -t head -o - Inter-Regular.ttf | \
  grep -E 'ascent|descent|lineGap|unitsPerEm'

Divide each hhea value by head.unitsPerEm and multiply by 100 to get the percentage you paste into the descriptor. Note that ttx cannot read WOFF2 directly in older versions — decompress first with woff2_decompress or point fonttools at the source TTF/OTF you subset from. Keeping the override values in your build's design-token file (alongside the size-adjust value) means a font upgrade is a one-line change followed by a re-run of the extraction script.

Per-platform overrides

Because system-ui resolves to a different face per OS, a single descriptor set is a compromise. If your RUM shows a meaningful split, generate one fallback face per platform and select with a media or scripting hint, measuring each against San Francisco (macOS/iOS), Segoe UI (Windows), and Roboto (Android). The ascent/descent of those three differ enough that the residual vertical shift on the un-targeted platforms can still register 0.01–0.02 CLS — small, but worth closing if you are chasing a perfect score.

Defensive variant

If unitsPerEm differs between releases of a font, or you can't be sure a browser honors the descriptors, guard the override and round conservatively. Browsers that don't support the descriptors should fall back to a plain, correct (if un-optimized) stack rather than a half-applied alias.

Guarded overrides with feature query

@supports (ascent-override: 90%) {
  @font-face {
    font-family: 'Inter Fallback';
    src: local('Arial');
    size-adjust: 107%;
    ascent-override: 96.9%;
    descent-override: 24.1%;
    line-gap-override: 0%;
  }
  :root { --font-body: 'Inter', 'Inter Fallback', system-ui, sans-serif; }
}

/* No descriptor support: skip the alias, use the plain stack. */
@supports not (ascent-override: 90%) {
  :root { --font-body: 'Inter', system-ui, sans-serif; }
}

Sanity-clamp the values: ascent + descent should land near 100–125% combined for typical Latin UI fonts. A sum far outside that range usually means you read OS/2.sTypoAscender against an hhea-based unitsPerEm mismatch, or grabbed the wrong table.

Verification

  1. Layout Shift Regions overlay. In Chrome DevTools open Rendering (⋮ → More tools → Rendering), enable Layout Shift Regions. Throttle to Slow 4G and reload — shifting areas flash blue. Before the override the text block flashes on swap; after, it should stay still.
  2. Performance panel. Record a reload with Web Vitals enabled and confirm the CLS value drops and the font-swap layout-shift entry under the text container disappears.
  3. PerformanceObserver check for a repeatable, scriptable signal:
let cls = 0;
new PerformanceObserver((list) => {
  for (const e of list.getEntries()) {
    if (!e.hadRecentInput) cls += e.value;
  }
  console.log('CLS so far:', cls.toFixed(4));
}).observe({ type: 'layout-shift', buffered: true });

If a vertical shift persists after this, the overrides are slightly off; if a horizontal shift remains, it's the size-adjust, not these descriptors. For deeper triage of which font is responsible, see debugging font-related layout shift.

Common pitfalls

  • Putting the descriptors on the web font. They belong on the fallback @font-face only. On the real font they distort its actual line box permanently.
  • Forgetting the sign on descent. hhea.descent is negative; the descent-override percentage must be positive — use Math.abs() before dividing.
  • Skipping line-gap-override. An unset gap lets the fallback inherit the system font's leading, so lines still differ in height even when ascent and descent match.
  • Using the wrong unitsPerEm. Dividing a 2048-unit metric by an assumed 1000 throws the percentage off by 2×. Always read unitsPerEm from the same font you took the metric from.
  • Matching verticals but ignoring horizontals. Without size-adjust, line-wrap points still differ and you trade a vertical shift for a horizontal one.

FAQ

Why do I need both ascent-override and size-adjust? They fix different shifts. ascent-override/descent-override/line-gap-override match the line-box height (vertical CLS); size-adjust matches glyph advance so lines wrap at the same place (horizontal reflow). A swap can shift in both directions, so both are usually required for CLS ≈ 0.

Which table — hhea or OS/2 — should I read the metrics from? Browsers use the metrics the platform reports, which on most systems track hhea (and the OS/2 USE_TYPO_METRICS flag can redirect to the sTypo* values). Reading hhea.ascent/hhea.descent via fontkit's font.ascent/font.descent matches Chromium's behavior in practice; verify the result with the Layout Shift Regions overlay rather than trusting the table blindly.

Do these descriptors have browser support I can rely on? Yes — ascent-override, descent-override, and line-gap-override are supported in Chromium 87+, Firefox 89+, and Safari 17+. Wrap them in an @supports (ascent-override: 90%) query so older Safari falls back to a plain, correct stack.

Does line-height interfere with the overrides? A unitless line-height (e.g. 1.5) multiplies the font's computed line box, so once ascent, descent, and gap match between fallback and web font, the same multiplier yields the same line height in both states. A fixed line-height in pixels can mask a metric mismatch — and reintroduce shift the moment you change the value — so prefer unitless line-height when you rely on metric overrides.

Related