How to use font metrics for perfect vertical rhythm
- Step 1Analyse every font in the layout — Run the analyser once per font — body, each heading weight, and your code/mono font. One file per run; there's no batch upload. Record `units_per_em`, the `typo` and `win` blocks, `x_height`, and `cap_height` for each.
- Step 2Take the per-font line-height the analyser computed — Don't hand-derive it — `recommended_line_height.safe` already equals `max((typoAsc + |typoDesc| + typoGap)/UPM, (winAsc + winDesc)/UPM, 1.0)`. Use `.typo` instead if you target only modern browsers and the font sets `USE_TYPO_METRICS`.
- Step 3Normalise cap-heights for headings — Compute cap-height ratio (`cap_height / units_per_em`) for the heading and body fonts. If the heading's ratio is larger, it looks bigger at the same px — drop the heading `font-size` by the ratio difference, or accept the variance deliberately.
- Step 4Match inline code x-height to body — Compute `x_height / units_per_em` for body and mono. If the mono ratio is smaller, inline `<code>` looks shrunken; bump its `font-size` (a common `0.875em–0.95em` lands it right when you size from the real ratio difference).
- Step 5Set leading as unitless tokens — Store each font's safe line-height as a CSS custom property and reference it where that font is used. Unitless values inherit as multipliers so nested smaller text keeps proportional leading — critical for rhythm.
- Step 6Verify against a baseline overlay — Drop a repeating-gradient baseline grid behind the page (CSS background) and confirm text sits on it. The analyser gives the numbers; the visual check confirms you applied them to the right elements at the right sizes.
Which reported metric drives which rhythm decision
All values come from the analyser's JSON. UPM normalises every raw number into a size-independent ratio.
| Rhythm problem | Metric(s) used | Decision |
|---|---|---|
| Lines feel cramped or too airy | recommended_line_height.safe (or .typo) | Use the font's own computed leading, per font |
| Heading looks bigger/smaller than body at same px | cap_height / units_per_em for both | Scale heading font-size by the cap-ratio difference |
| Inline code looks shrunken next to body | x_height / units_per_em for both | Bump code font-size by the x-ratio difference |
| One paragraph breathes more than the rest | typo vs win block | Standardise on one set; safe takes the larger |
| Accents clip on Windows | win.ascent / win.descent | Prefer the win-based line-height |
Worked cap-height matching example
Two fonts both at 1000 UPM. The cap-height ratio difference tells you how much to rescale the heading so the capitals optically match.
| Font | cap_height | cap ratio | Size correction vs body |
|---|---|---|---|
| Body (Inter-ish) | 727 | 0.727 | baseline (1.000×) |
| Heading A (tall caps) | 700 | 0.700 | heading × (0.727 / 0.700) = 1.039 to match |
| Heading B (short caps) | 750 | 0.750 | heading × (0.727 / 0.750) = 0.969 to match |
Per-font leading tokens from real metrics
Illustrative recommended_line_height.safe values for three roles. Replace with your own analyser output — the point is each font gets its own value.
| Role | Example safe line-height | Token |
|---|---|---|
| Body | 1.50 | --leading-body: 1.5; |
| Heading | 1.20 | --leading-heading: 1.2; |
| Code / mono | 1.45 | --leading-code: 1.45; |
Cookbook
Real rhythm calculations from analyser output. The leading values come straight from recommended_line_height; the ratios you compute from the reported cap/x numbers. For zero-CLS fallback matching of these same fonts see the fallback-matching guide, and build the full size ramp with typography-scale-builder.
Per-font leading instead of a global 1.5
ExampleThree fonts, three different natural line-heights. Forcing 1.5 on all three makes the heading too loose and the body about right by luck.
Analyser output (recommended_line_height.safe):
Body font → 1.50
Heading font→ 1.20
Mono font → 1.45
CSS:
:root {
--leading-body: 1.5;
--leading-heading: 1.2;
--leading-code: 1.45;
}
body { line-height: var(--leading-body); }
h1, h2, h3 { line-height: var(--leading-heading); }
code, pre { line-height: var(--leading-code); }Cap-height correcting a heading font
ExampleHeading capitals print smaller than body capitals at the same font-size because its cap ratio is lower. Rescale to match.
Body: cap_height 727 / UPM 1000 = 0.727
Heading: cap_height 700 / UPM 1000 = 0.700
Correction factor = 0.727 / 0.700 = 1.039
/* If body is 16px and you want H-caps to match optically: */
h2 { font-size: calc(2rem * 1.039); } /* ~33.2px not 32px */
/* The analyser supplies cap_height; you choose to correct or not. */Sizing inline code to body x-height
ExampleMono fonts often have a larger x-height ratio than the body font, so default-sized inline code looks oversized. Size it down by the ratio difference.
Body x-height ratio: 515 / 1000 = 0.515
Mono x-height ratio: 530 / 1000 = 0.530
Mono reads ~3% bigger at the same px:
code { font-size: calc(1em * 0.515 / 0.530); } /* ≈ 0.972em */
→ inline code now sits at the same optical height as body text.Diagnosing a paragraph that breathes too much
ExampleOne font's win metrics are far larger than its typo metrics. On a browser that uses win, that paragraph gets a taller line box than the rest.
Suspect font analyser output:
recommended_line_height.typo = 1.16
recommended_line_height.win = 1.42 ← much larger
Diagnosis: win bounds are inflated (extra diacritic clearance).
Fix: pin an explicit line-height so the renderer can't pick win:
.prose p { line-height: 1.5; } /* normalises across renderers */A baseline-grid sanity overlay
ExampleAfter applying per-font leading and cap corrections, drop this grid behind the content and confirm text sits on the lines.
body {
background-image: repeating-linear-gradient(
to bottom,
transparent 0,
transparent calc(1.5rem - 1px),
rgba(255,0,0,0.15) 1.5rem
);
background-size: 100% 1.5rem;
}
/* 1.5rem grid step = body line-height × body font-size (24px). */Edge cases and what actually happens
Every row below was probed against the live API. Some documented requirements (alphabetical axis order, numerical tuple order) are not actually enforced in practice — useful to know if you've been blaming the wrong thing for a 400.
Font omits cap-height, so you can't size-match
Preserved as nullIf sCapHeight is absent (older OS/2 versions, some display fonts), cap_height is null and the pill shows ?. You can't compute the cap ratio for size-matching. Workaround: measure the H glyph's bounding box in glyph-inspector, which reports a real bbox per glyph, and divide its height by UPM yourself.
typo and win disagree, so 'natural' line-height is browser-dependent
By designVertical rhythm cracks when the same font renders one line box on a USE_TYPO_METRICS browser and a taller one elsewhere. The analyser reports both typo and win. To stop the drift, set an explicit line-height (e.g. the safe value) so the renderer never falls back to the natural — that's the whole reason to read the metrics.
Variable heading font at a heavy weight
Default-instance metricsThe analyser reads the static (default-instance) metrics, not interpolated MVAR values. A heading set at wght 800 from a variable font may have slightly different vertical metrics than the wght 400 numbers you read. If your rhythm depends on it, freeze the heavy instance with variable-font-freezer and re-analyse.
Mixing a 2048-UPM and a 1000-UPM font
Handled — ratios normalise itDifferent UPM values look alarming side by side, but every metric divides by units_per_em, so a 2048-UPM TrueType and a 1000-UPM CFF font compare cleanly on ratios. Compute cap and x ratios, not raw numbers, when matching — the raw numbers aren't comparable across UPM, the ratios are.
Pixel line-height inherited into nested smaller text
Anti-pattern — use unitlessIf you set line-height: 24px on body and a child drops to 12px text, the child still gets a 24px line box — destroying rhythm. The analyser's css_recommendation is always unitless precisely so it inherits as a multiplier. Keep it unitless throughout.
Bare TrueType with no OS/2 table
Partial — falls back to 1.0No OS/2 means typo/win/x-height/cap-height are all null and safe floors at 1.0 — too tight for rhythm. Derive a usable leading from the hhea block instead ((hhea.ascender + |hhea.descender| + hhea.line_gap) / units_per_em), which the analyser does report, and set it explicitly.
Emoji or icon font in the stack
Metrics may be unusualColour/emoji fonts and icon fonts often have non-standard vertical metrics (large win bounds, zero x-height). Their line-height shouldn't drive your body rhythm. Analyse them only to confirm they won't blow out a line where they appear inline, and pin an explicit line-height on elements that mix them with text.
Two weights of the same family report identical metrics
ExpectedStatic Regular and Bold of the same family usually share OS/2/hhea metrics, so they report the same line-height and cap/x ratios — that's correct and means one leading token covers the whole family. Italics occasionally differ slightly; analyse each face you actually ship to be sure.
Frequently asked questions
Why doesn't line-height: 1.5 work for every font?
1.5 is a fine default for some fonts and wrong for others — it ignores each font's actual ascender/descender/line-gap. A font with tall ascenders wants more; one with shallow descenders can use less. The analyser computes the value each font actually wants (recommended_line_height), so headings, body, and code each get their own leading instead of one number that fits none of them perfectly.
Does font-size change the metrics?
No. Metrics are stored in font-internal units relative to unitsPerEm, so they're size-independent. At 16px or 48px the same font has the same line-height ratio and the same cap/x ratios. That's why you store leading as a unitless multiplier and cap/x corrections as ratios — they hold at every size.
How do I match cap-heights between two fonts?
Compute each font's cap ratio (cap_height / units_per_em) from the analyser output. If the heading's ratio is smaller than the body's, scale the heading font-size up by bodyRatio / headingRatio; if larger, scale down. The analyser gives the raw cap_height numbers — you apply the correction in CSS.
Does the tool draw a baseline grid or output the rhythm CSS?
No — it measures, it doesn't lay out. It returns the metric numbers and a single line-height recommendation. You build the baseline grid and apply the cap/x corrections yourself. The examples above include a paste-in baseline overlay so you can verify your work.
Should I use the typo or win line-height for rhythm?
If you target modern browsers and the font sets USE_TYPO_METRICS, typo is the designer's intent and usually tighter. If you support older Windows renderers or have seen accents clip, win is safer. When you just want one value that's correct everywhere, use safe (the larger of the two) and set it explicitly so the renderer never picks the other.
Can I analyse all my fonts in one go?
Not in the UI — it's one file per run. For a whole /fonts folder, script opentype.js yourself (see the design-tokens guide) or, on a paid tier, POST each file to the local runner at http://127.0.0.1:9789/v1/tools/font-metrics-analyzer/run. Either way the per-font numbers are identical to the web tool.
Why does my inline code look bigger than the body text?
Mono fonts frequently have a larger x-height ratio than your body font, so at the same em size they read bigger. Compute both x ratios from the analyser, then size code by bodyXRatio / monoXRatio (often around 0.9–0.95em). That lands inline code at the same optical height as the surrounding text.
What about italics — do they have different metrics?
Usually italics share the family's vertical metrics, but not always. The analyser reads whatever face you give it, so analyse the italic file directly if your design leans on it. The metric that most often differs is the slanted descender, which can affect win descent and therefore the win line-height.
Is the brand font I'm profiling uploaded anywhere?
No. The whole analysis runs in your browser with opentype.js. The result panel shows a '0 bytes uploaded' badge. This matters for licensed or pre-release brand fonts you can't legally hand to a third-party service — they never leave the page.
How do I keep leading consistent when the type scale changes?
Store leading as its own token (--leading-body, etc.) separate from the size scale. Changing the scale ratio in typography-scale-builder then adjusts sizes without touching leading, because each line-height is a unitless multiplier of whatever size lands on the element.
Does subsetting break vertical rhythm?
No. Subsetting with font-subsetter removes glyphs but leaves head.unitsPerEm and the OS/2/hhea metric fields untouched, so the line-height and cap/x ratios are identical before and after. Analyse either the full font or its subset — same rhythm numbers.
What if a font in my stack has no cap-height?
You'll see null / ? for cap_height. Size-matching that font by caps isn't possible from the JSON alone. Measure the H glyph in glyph-inspector — it reports a per-glyph bounding box and viewBox — then divide the cap glyph's height by units_per_em to get the ratio yourself.
Privacy first
Every JAD Font tool runs entirely in your browser using opentype.js and the wawoff2 WASM Brotli encoder. Your fonts never leave your device — verified by zero outbound network requests during processing.