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 area, 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 dedicated walkthrough for isolating the exact node is finding the font causing CLS in DevTools, and it pairs well with this pipeline once you have a suspect element.
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
The six steps below turn a font binary into locked CSS. Treat them as a pipeline you re-run on every font update, not a one-time manual calculation — the diagram maps each step to the artifact it produces.
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. If you would rather not hand-derive the ratios, Capsize wraps the same fontTools extraction in a small JS library and additionally trims the leading above cap-height and below the baseline, which is useful when you also want deterministic line-box cropping rather than just override percentages. 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.
Worked Example: Auditing a Production Type Stack
Walk through concrete numbers rather than placeholders, using a stack of Inter (headings and body) falling back to Arial on Windows and Helvetica on macOS. Running Step 1 against inter.woff2 produces unitsPerEm: 2048, OS/2.sCapHeight: 1490, OS/2.sxHeight: 1096, hhea.ascent: 2728, hhea.descent: -680, hhea.lineGap: 0. Normalizing in Step 2 gives capHeightRatio: 0.727, xHeightRatio: 0.535, ascentRatio: 1.332, descentRatio: 0.332.
Dumping the same tables for Arial yields unitsPerEm: 2048, sxHeight: 1062, giving xHeightRatio: 0.519. Step 3's ratio is 0.535 / 0.519 = 1.031, so the naive size-adjust would be 103.1% — deliberately close to 100% because Arial and Inter's x-heights are already similar; a mismatched pairing like Inter against a narrow system serif can require size-adjust values anywhere from 90% to 145% depending on the fallback's own x-height. Step 4 then converts the Inter ratios directly: ascent-override: 133.2%, descent-override: 33.2%, line-gap-override: 0%. Note that ascent-override and descent-override here exceed 100% combined because hhea ascent/descent in Inter extend further than a single em square — this is normal for fonts with generous internal leading, and the descriptors accept any positive percentage, not just values under 100%.
Emitting this through Step 5's template produces:
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
size-adjust: 103.1%;
ascent-override: 133.2%;
descent-override: 33.2%;
line-gap-override: 0%;
}
Running the CI audit from Step 6 on a sample article page (2,400 words, three heading levels) before this fix showed a font-swap CLS contribution of 0.087 — most of it from the <h1> re-flowing as its larger font size amplified the same proportional mismatch. After deploying the tuned fallback, the same page scored 0.031, comfortably under the 0.1 field threshold and close to the stricter 0.05 build-time budget teams often set for text-heavy templates. The lesson generalizes: because CLS scales with the moved element's viewport area, headings and large display type expose a metric mismatch far more visibly than body copy at the same percentage error, so audit your largest heading level specifically rather than trusting a body-text spot check.
Edge Cases
Variable fonts with an opsz or italic axis. A variable font's glyph outlines can shift meaningfully between wght or opsz instances, which changes the effective cap-height and x-height at each end of the axis even though the static OS/2/hhea tables in the binary do not change per-instance. Extract the manifest at the weight and optical size your body copy actually renders at (usually wght 400, opsz 14–16 for body text), not at the default instance, and re-verify at your heaviest display weight separately if it uses a different fallback.
CJK and mixed-script fallback stacks. A Latin metric override does nothing for CJK glyphs rendered through a different fallback in the same stack — each script segment picks its own font from the font-family list, and CJK system fonts (PingFang SC, Noto Sans CJK, Microsoft YaHei) have wildly different vertical metrics from Latin faces. Treat each script's fallback as a separate override problem; see fallback stacks for CJK and emoji fonts for the stacking approach, then apply this same extraction pipeline per script family.
Icon fonts sharing a text run. Icon fonts frequently ship with sCapHeight and sxHeight values that describe pictogram bounding boxes rather than letterforms, and applying a text-derived size-adjust to an icon-font fallback distorts icon glyphs instead of fixing anything. Keep icon fonts out of this pipeline entirely and address their layout shift with icon-specific sizing, covered in fixing layout shift from icon fonts.
Print stylesheets. size-adjust and the *-override descriptors apply in print media too, but printed pages rarely swap fonts mid-render the way a browser tab does, so the CLS concern is moot — the residual risk is print layout using a stale fallback manifest computed for a screen-only web font that differs from the print font stack. Regenerate the manifest per @media print font stack if the two diverge.
Zero-width or missing glyphs in the fallback. If the named fallback (local("Arial")) is not installed — common on Linux desktops and some Android OEM skins — the browser falls through to the next family in the stack, and your carefully tuned overrides do not apply to whatever renders instead. Always terminate the stack with a generic family (sans-serif) and treat the tuned fallback as a best-effort optimization for the common case, not a guarantee.
Automating the Pipeline in CI
Manual recalculation is the pitfall that reintroduces drift, so wire Steps 1–5 into a build script that runs whenever the font binary changes, and wire Step 6 into a Lighthouse CI budget so a regression fails the build instead of shipping.
Regenerate fallback CSS on every font update
#!/usr/bin/env bash
set -euo pipefail
FONT="src/fonts/inter.woff2"
OUT="src/styles/_font-fallback.css"
python3 scripts/emit_fallback_css.py "$FONT" "Inter" 'local("Arial")' > "$OUT"
git diff --exit-code "$OUT" || {
echo "Fallback CSS changed — commit the regenerated file."
exit 1
}
Pair this with a byte-and-metric budget check in the same pipeline; the companion workflow for wiring Lighthouse's cumulative-layout-shift audit into CI with a hard failure threshold is automating font budget checks with Lighthouse CI. Running both checks together means a font update that silently changes unitsPerEm, subsets away glyphs the CI page happens to use, or ships a new sCapHeight gets caught before it reaches production, rather than showing up weeks later as an unexplained CLS regression in field data.
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.
Two named fallbacks for a heading/body split
@font-face {
font-family: "Inter Display Fallback";
src: local("Arial");
size-adjust: 112%;
ascent-override: 128%;
descent-override: 30%;
}
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
size-adjust: 103.1%;
ascent-override: 133.2%;
descent-override: 33.2%;
}
h1, h2 { font-family: "Inter Display", "Inter Display Fallback", Arial, sans-serif; }
body { font-family: "Inter", "Inter Fallback", Arial, sans-serif; }
Headings and body copy often load different weights or optical sizes of the same variable font family; because the worked example above showed CLS scaling with element area, giving the heading its own manifest-derived fallback is worth the extra @font-face block.
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.
Do these overrides need to be recomputed if I only change the font subset, not the typeface?
Usually not — subsetting with tools like pyftsubset or glyphhanger removes glyph outlines but leaves head, hhea, and OS/2 untouched unless the subsetting tool explicitly recalculates bounding boxes (some do, to shrink head.xMax/yMax, though unitsPerEm, sCapHeight, sxHeight, and the hhea ascent/descent values are essentially never touched by subsetting). Re-run the extraction step anyway as a cheap CI check rather than assuming — a font vendor update bundled with a subset regeneration is a common way stale overrides slip through unnoticed.