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.

Annotated font metrics on the em square A glyph row showing baseline, cap-height, x-height, ascent, and descent measured against the em square. Hxg ascent top cap-height x-height baseline descent OS/2 sCapHeight OS/2 sxHeight hhea ascent + descent
Each CSS override targets one of these regions; mismatched fallback values shift the baseline and register as CLS.

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-face so 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/usWinDescent instead of hhea ascent/descent. The two table pairs disagree, and mixing them produces overrides that are correct on one engine and a pixel off on another. Decide on hhea as the source of truth and stay consistent.
  • Skipping size-adjust and 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-size for a reason; bake in pixels and the alignment breaks the moment the element scales responsively.
  • Forgetting line-height interaction. A large size-adjust changes the effective glyph size, which changes the computed line box; pair the override work with an explicit unitless line-height or tight grid containers will overflow.
  • Never re-auditing after a font swap-out. Fonts get updated, subsets get regenerated, and unitsPerEm or 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.

Related