Font Metrics & Baseline Alignment: Implementation Workflow for Web Typography
Establish a deterministic pipeline for cross-browser baseline alignment by extracting raw font table data and normalizing it into CSS overrides. This workflow is part of the Typography Fundamentals & System Architecture blueprint, and it focuses on parsing the hhea, OS/2, and head tables to resolve ascender/descender drift before deployment. The target outcome is measurable: a Cumulative Layout Shift (CLS) contribution from the font swap below 0.05 and sub-pixel baseline drift across the WebKit and Blink engines, well inside the CLS < 0.1 Core Web Vitals threshold.
Problem Framing: The Swap Shift You Can Measure
The failure this workflow addresses is the vertical jump that happens when a web font finishes loading and replaces the fallback face. If the fallback and the web font disagree on ascent, descent, or overall scale, every line of text below the swapped block moves, and the browser scores that movement as layout shift. On a content-heavy page the cumulative effect routinely pushes CLS past 0.1 and tanks the field score.
Start with a diagnostic rather than guessing at percentages. Open Chrome DevTools, enable Rendering → Layout Shift Regions, then reload with the cache disabled and the network throttled to Slow 3G so the swap is slow enough to watch. Highlighted regions that flash on the text block at the moment the font arrives are the shift you are chasing. Cross-check in the Performance panel: record the load, expand the Experience track, and click each layout-shift entry — the Summary names the moved node and the shift score, and a font swap shows up as a shift whose timestamp coincides with the WOFF2 request finishing in the Network panel.
The root cause is almost always a metric mismatch, not a font-display problem. font-display controls when the swap happens; metric overrides control whether the swap costs anything. Configure font-display values to decide the swap timing, then use the override descriptors below to make the swap itself dimensionally invisible. Every metric you extract here maps onto one of four em-square regions — ascent above the baseline, descent below it, and the cap-height and x-height bands that define perceived text size. The diagram below annotates these regions so the override percentages later in this guide are unambiguous.
Baseline Configuration: The Minimum Correct Setup
Before any optimization, you need three things in place: the primary web font declared with an explicit font-display, a named fallback @font-face that exists solely to carry the override descriptors, and a font-family chain that puts the named fallback between the web font and the generic family. The cardinal rule — repeated throughout this guide because it is the one most teams get backwards — is that metric overrides go on the fallback @font-face, never on the primary web font. You are reshaping the fallback to match the web font's box, not the reverse.
Minimum metric-matched fallback setup
/* 1. The real web font */
@font-face {
font-family: "Inter";
src: url("/fonts/inter.woff2") format("woff2");
font-display: swap;
}
/* 2. A named fallback whose box matches Inter — overrides live HERE */
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
size-adjust: 107%;
}
/* 3. Chain them so the swap lands on the tuned fallback */
:root {
--font-body: "Inter", "Inter Fallback", Arial, sans-serif;
}
body { font-family: var(--font-body); }
The four descriptors do distinct jobs. size-adjust scales the whole glyph set so the fallback's x-height and cap-height match the web font — it is the coarse, do-it-first control. ascent-override and descent-override set the line box explicitly as a percentage of font size, fixing where the baseline sits. line-gap-override neutralizes any external leading baked into the fallback's metrics. The percentage math behind these is covered in detail in ascent-override and descent-override to reduce CLS; this page is about the end-to-end pipeline that produces those numbers deterministically.
Verification: With the fallback chained, throttle to Slow 3G, reload with Layout Shift Regions on, and watch the text block as the web font arrives. A correctly tuned fallback produces no highlighted region at the swap moment. If text still jumps, your override percentages are derived from the wrong tables — which the workflow below fixes.
Step-by-Step Workflow
Step 1 — Extract the raw tables
Pull the metric tables out of the binary font so you are working from ground truth rather than eyeballed values. Use fonttools to dump OS/2, hhea, and head.
Extract metric tables with TTX
pip install fonttools
ttx -t OS/2 -t hhea -t head -o - inter.woff2
This prints the three tables as XML. The values you need are head.unitsPerEm, OS/2.sCapHeight, OS/2.sxHeight, hhea.ascent, hhea.descent, and hhea.lineGap. Verify the dump succeeded by confirming all three table blocks appear in the output and unitsPerEm is non-zero (it is 1000 for most CFF/OpenType fonts and 2048 for many TrueType faces) — a missing OS/2 block means the font is malformed and downstream ratios will be wrong.
Step 2 — Normalize against the em square
Convert every raw design unit into a proportion of the em square by dividing by unitsPerEm. This is what makes the numbers portable across fonts with different unitsPerEm values and directly usable as CSS percentages.
Normalize metrics into override-ready ratios
from fontTools.ttLib import TTFont
import json
def metrics_manifest(path):
font = TTFont(path)
upm = font["head"].unitsPerEm
os2, hhea = font["OS/2"], font["hhea"]
return {
"unitsPerEm": upm,
"capHeightRatio": os2.sCapHeight / upm,
"xHeightRatio": os2.sxHeight / upm,
"ascentRatio": hhea.ascent / upm,
"descentRatio": abs(hhea.descent) / upm,
"lineGapRatio": hhea.lineGap / upm,
}
print(json.dumps(metrics_manifest("inter.woff2"), indent=2))
Verify that ascentRatio + descentRatio + lineGapRatio is close to the font's natural line height (typically 1.1–1.3); a sum far from that range signals you read usWinAscent/usWinDescent from the wrong table instead of the hhea values.
Step 3 — Derive size-adjust from the x-height delta
size-adjust is the proportion that scales the fallback so its x-height matches the web font's. Compute it as the ratio of the web font's x-height to the fallback's x-height, expressed as a percentage. For example, if Inter's x-height ratio is 0.727 and Arial's is 0.519, the fallback needs size-adjust: 140% to match — though in practice you tune toward visual parity. The precise cap-height conversion is covered in how to calculate cap-height for web typography, and the x-height nuances in understanding x-height differences in web fonts. Verify by overlaying the fallback and web font at the same font-size in a browser tab — the lowercase letters should sit at the same height once size-adjust is applied.
Step 4 — Derive ascent/descent overrides from the line box
After size-adjust matches scale, set the line box. ascent-override is the web font's ascentRatio as a percentage; descent-override is its descentRatio as a percentage; line-gap-override is almost always 0% because the gap is folded into the scaled fallback. These three numbers come straight from the manifest in Step 2. Verify by rendering a single line of fallback text and a single line of web-font text in stacked boxes — the baselines and the box tops/bottoms should align within a pixel.
Step 5 — Emit the CSS from the manifest
Generate the fallback @font-face programmatically so the numbers never drift from the manifest. A tiny template turns the JSON into the descriptor block, and binding the ratios to CSS custom properties lets component themes reuse them.
Generate the fallback @font-face from the manifest
def emit_face(name, fallback_src, m, size_adjust):
return f"""@font-face {{
font-family: "{name} Fallback";
src: {fallback_src};
size-adjust: {size_adjust:.1f}%;
ascent-override: {m['ascentRatio']*100:.1f}%;
descent-override: {m['descentRatio']*100:.1f}%;
line-gap-override: {m['lineGapRatio']*100:.1f}%;
}}"""
print(emit_face("Inter", 'local("Arial")', metrics_manifest("inter.woff2"), 107.0))
Verify the emitted CSS parses without warnings in DevTools and that the rendered @font-face appears under Elements → Computed → Rendered Fonts for fallback text before the web font loads.
Step 6 — Audit CLS and lock it into CI
Treat the result as a measurable budget, not a one-off. Capture CLS before and after with a Lighthouse run or a PerformanceObserver watching layout-shift entries, and assert that the font-swap contribution is below your threshold. Tie this into line height & vertical rhythm so the overrides and the rhythm grid agree on the line box. Verify by diffing two Lighthouse JSON reports: the cumulative-layout-shift audit number should drop, and the layout-shift breakdown should no longer attribute movement to the text node that swaps.
Observe the swap shift directly
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
console.log("CLS chunk", entry.value.toFixed(4), entry.sources);
}
}
}).observe({ type: "layout-shift", buffered: true });
A correctly metric-matched fallback emits no layout-shift entry at the moment the WOFF2 request resolves.
Browser Compatibility & Fallback Matrix
| Descriptor | Chrome / Edge | Firefox | Safari | Notes |
|---|---|---|---|---|
size-adjust |
92+ | 92+ | 16.4+ | Scales whole glyph set; apply first |
ascent-override |
87+ | 89+ | 17+ | Sets line-box top as % of font-size |
descent-override |
87+ | 89+ | 17+ | Sets line-box bottom; use absolute value |
line-gap-override |
87+ | 89+ | 17+ | Usually 0% for tuned fallbacks |
local() fallback src |
all | all | all | Resolves to an installed face only |
Two engine quirks shape the defensive strategy. First, WebKit historically derived the baseline from hhea ascent/descent while Blink prioritized OS/2 usWinAscent/usWinDescent; modern stable releases have converged on a consistent line-box model, but legacy Safari can still place the baseline a pixel differently, so visual QA must include at least one WebKit build. Second, Safari shipped the override descriptors much later than Chromium and Firefox (the *-override trio landed in 17, size-adjust in 16.4), so pre-17 Safari simply ignores the descriptors and falls back to the raw fallback metrics. Wrap the optimization in @supports (size-adjust: 100%) if you need to branch behavior, and accept that pre-17 Safari sees a small residual swap rather than a broken layout. Because local() only resolves to fonts the user already has installed, never depend on a single named fallback — chain a generic family last so the line box is still defined when Arial is absent.
More Configuration Examples
Token-driven overrides for theme reuse
:root {
--cap-height-ratio: 0.727;
--x-height-ratio: 0.519;
}
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
Branching with @supports for pre-17 Safari
/* Baseline: undecorated fallback everywhere */
:root { --font-body: "Inter", Arial, sans-serif; }
/* Enhanced: route through the tuned fallback where overrides exist */
@supports (size-adjust: 100%) {
:root { --font-body: "Inter", "Inter Fallback", Arial, sans-serif; }
}
Per-weight fallbacks for a variable font
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
font-weight: 100 900;
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
}
Declaring the fallback across the same font-weight range as the variable web font keeps the line box stable as the wght axis interpolates, rather than snapping when the weight crosses a boundary.
Common Pitfalls
- Applying overrides to the primary web font. The descriptors belong on the fallback
@font-faceso it grows or shrinks to match the web font. Put them on the real font and you distort the typeface you actually want users to see, while leaving the swap shift untouched. - Reading
usWinAscent/usWinDescentinstead ofhheaascent/descent. The two table pairs disagree, and mixing them produces overrides that are correct on one engine and a pixel off on another. Decide onhheaas the source of truth and stay consistent. - Skipping
size-adjustand trying to fix everything with ascent/descent. Without first matching scale, the x-heights differ and no line-box tweak makes the running text look the same — it just changes where the mismatched glyphs sit. - Hardcoding pixel overrides. The descriptors are percentages of
font-sizefor a reason; bake in pixels and the alignment breaks the moment the element scales responsively. - Forgetting
line-heightinteraction. A largesize-adjustchanges the effective glyph size, which changes the computed line box; pair the override work with an explicit unitlessline-heightor tight grid containers will overflow. - Never re-auditing after a font swap-out. Fonts get updated, subsets get regenerated, and
unitsPerEmor x-height can change. Without a CI CLS check, a routine font update silently reintroduces the shift you fixed.
Frequently Asked Questions
How does size-adjust differ from ascent-override in baseline alignment?
size-adjust scales the entire glyph set proportionally — it is the primary tool for matching cap-height and x-height between the fallback and the primary font, so you apply it first. ascent-override explicitly defines the vertical distance from the baseline to the top of the line box as a percentage of font size. Use size-adjust to match overall scale, then ascent-override and descent-override to fine-tune the line box and baseline position. They are complementary, not interchangeable.
Should I derive overrides from hhea or OS/2 metrics?
Pick one source and apply it consistently; mixing the two is the most common cause of a per-engine pixel difference. Modern browsers have converged on a line-box model close to hhea ascent/descent, so deriving ascent-override and descent-override from hhea (normalized against unitsPerEm) is the most predictable choice today. Always validate the result on a real WebKit build, since legacy Safari can still differ slightly.
Can variable font axes interfere with static metric overrides?
Yes. Interpolating the opsz or wght axes can shift the internal glyph bounding boxes, so a fallback tuned at one weight drifts at another. Declare the fallback @font-face across the same font-weight range as the variable font and calibrate the overrides at the most-used weight. Where an axis moves the box beyond your tolerance, branch the line-height with @supports or adjust it in JS at the breakpoint.
What is the performance impact of calculating font metrics at runtime?
Measuring metrics in the browser with the Canvas API forces synchronous layout and can thrash the critical rendering path. Do the extraction at build time with fonttools, emit the override percentages as static CSS or custom properties, and ship those — runtime measurement buys you nothing here because the metrics are fixed properties of the font binary.