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
hheaandOS/2tables you can read. - A named fallback
@font-facealiasing a system font vialocal()(the override descriptors only work on a@font-faceblock, never on a plainfont-family). fontkit(npm i fontkit) orfonttoolsto extract metrics, plusunitsPerEmfor 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 thehhea.ascent(orOS/2.sTypoAscender) value.descent-override— depth below the baseline, fromhhea.descent(OS/2.sTypoDescender), entered as a positive percentage.line-gap-override— extra leading the font requests between lines, fromhhea.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.
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
- 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.
- 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.
- 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-faceonly. On the real font they distort its actual line box permanently. - Forgetting the sign on descent.
hhea.descentis negative; thedescent-overridepercentage must be positive — useMath.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 readunitsPerEmfrom 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.