How to Calculate Cap-Height for Web Typography: Metrics, CSS Math & Layout Stability
This guide is part of the Font Metrics & Baseline Alignment workflow within the broader Typography Fundamentals & System Architecture blueprint.
Problem Statement
Browsers compute line-box height from a font's internal vertical metrics — the ascent, descent, and line-gap declared in the hhea and OS/2 tables — not from the visible bounds of the uppercase glyphs you actually see. Cap-height is the distance from the baseline to the top of a flat capital like H, and it is almost never equal to font-size: across common text faces it lands between 65% and 75% of the em. When you align an icon, a rule, or a sibling element to the visual top of capital letters but the browser positions the line box by metric ascent, you get spacing that looks wrong at one size and worse at another. The same gap drives layout instability: if a web font and its fallback expose different cap-heights at the same font-size, the line box resizes the instant the web font swaps in, and every node below it jumps — a measurable Cumulative Layout Shift (CLS). The narrow task this page solves is computing the cap-height ratio from the font's own tables and wiring it into CSS so vertical alignment is exact and swap-induced CLS goes to zero.
Prerequisites
- The font binary on disk in a parsable format (
.ttf,.otf, or.woff2);.woff2works withopentype.jsandfonttools4.x but not older parsers. - Node.js with
opentype.jsinstalled (npm i opentype.js) or a Python environment withfonttools(pip install fonttools), for reading theOS/2andglyftables. - A way to set CSS custom properties on
:rootso the extracted ratio is declared once and referenced everywhere. - Chrome DevTools for verification — the Computed pane's Rendered Fonts line and the box-model overlay.
Diagnostic starting point: open DevTools → Elements → Computed, compare the computed line-height against the rendered cap-height using the box-model overlay. If the visible capitals do not sit where your spacing math predicts, the cap-height ratio is the missing variable.
Extract Raw Cap-Height from Font Tables
Parse the OS/2 table for the sCapHeight field. If absent — fonts predating OS/2 version 2, and some legacy faces, omit it — fall back to measuring the bounding box of an uppercase glyph (H is the canonical choice because it is flat-topped and unaffected by overshoot) from the glyf table.
Normalize the raw integer: capRatio = rawValue / unitsPerEm. The result is a unitless ratio relative to the font's design grid, independent of the rendered point size, so the same number drives 12px captions and 64px headings alike. Most text faces report unitsPerEm of 1000 (PostScript/CFF outlines) or 2048 (TrueType outlines); never assume one, always read it, because a 2048-unit sCapHeight of 1462 and a 1000-unit value of 714 describe nearly the same ratio and mixing the divisors silently doubles your error.
Validate extraction via the fonttools CLI or opentype.js inspection before committing values to your CSS variables. Cross-reference baseline offsets using Font Metrics & Baseline Alignment to prevent vertical drift when mixing typefaces.
| Field | Table | Typical value | Meaning |
|---|---|---|---|
unitsPerEm |
head |
1000 or 2048 | Design-grid resolution; the divisor |
sCapHeight |
OS/2 (v2+) |
~700 / ~1450 | Baseline-to-cap-top in font units |
sxHeight |
OS/2 (v2+) |
~500 / ~1050 | Baseline-to-x-top in font units |
H glyph yMax |
glyf |
matches cap | Fallback when sCapHeight is 0/absent |
Primary extraction — read sCapHeight, normalize by unitsPerEm
// Using opentype.js in Node.js
const opentype = require('opentype.js');
const font = opentype.loadSync('./font.woff2');
const unitsPerEm = font.unitsPerEm; // e.g. 1000 or 2048
// sCapHeight lives in the OS/2 table (version >= 2)
const rawCap = font.tables.os2.sCapHeight ||
font.charToGlyph('H').getMetrics().yMax; // fall back to glyph bounds
const capRatio = rawCap / unitsPerEm; // unitless, size-independent
console.log(`Cap-height ratio: ${capRatio.toFixed(4)}`);
Annotated, line by line:
opentype.loadSync('./font.woff2')parses the binary and exposes its SFNT tables as plain objects;unitsPerEmcomes off theheadtable and is the only correct divisor — hardcoding 1000 or 2048 is the most common source of wrong ratios.font.tables.os2.sCapHeightreads the declared cap-height directly. When the field is present and non-zero, this is authoritative and matches what the type designer intended.|| font.charToGlyph('H').getMetrics().yMaxis the fallback path:getMetrics()returns the glyph's bounding box, andyMaxis the top edge ofHin font units. BecauseHhas no overshoot, itsyMaxis a faithful proxy for cap-height.rawCap / unitsPerEmcollapses both branches to the same unitless scale. A ratio of0.7180means cap-height is 71.8% of the em — multiply by anyfont-sizeto get cap-height in pixels.
This handles the common case but assumes the file loads cleanly and that either sCapHeight or the H glyph resolves. Production fonts violate both assumptions: corrupt subsets, fonts with sCapHeight: 0 (which is falsy and silently triggers the fallback even when intentional), and display faces with no H all need defensive handling.
Error Handling & Edge-Case Variant
The defensive version validates the parse, distinguishes a genuine zero from a missing field, and refuses to emit a ratio it cannot trust rather than poisoning your CSS with NaN or a guess.
Defensive extraction with validation and glyph fallback
const opentype = require('opentype.js');
function capRatio(path) {
let font;
try {
font = opentype.loadSync(path);
} catch (err) {
throw new Error(`Cannot parse font ${path}: ${err.message}`);
}
const upm = font.unitsPerEm;
if (!upm || upm <= 0) throw new Error('Invalid unitsPerEm');
const os2 = font.tables.os2 || {};
let raw = os2.sCapHeight; // may be undefined OR a real 0
// Treat 0/undefined as "not declared" and measure the H glyph instead
if (!raw) {
const H = font.charToGlyph('H');
const m = H && H.unicode ? H.getMetrics() : null;
if (!m || m.yMax == null) {
throw new Error('No sCapHeight and no usable H glyph');
}
raw = m.yMax;
}
const ratio = raw / upm;
if (ratio <= 0 || ratio > 1.2) { // sanity-bound the result
throw new Error(`Implausible cap ratio: ${ratio}`);
}
return Number(ratio.toFixed(4));
}
try {
console.log('cap ratio:', capRatio('./font.woff2'));
} catch (e) {
console.error('cap-height extraction failed:', e.message);
process.exit(1); // fail the CI build, don't ship a guess
}
The try/catch around loadSync turns a corrupt or unsupported file into a clear error instead of a stack trace. The if (!raw) branch deliberately catches both undefined and a literal 0, because a 0 in sCapHeight means "designer left it blank" far more often than "this font has no capitals." The upper sanity bound (ratio > 1.2) catches the classic divisor mix-up — a 2048-unit value divided by a 1000 unitsPerEm would overflow past 1.0 and is rejected before it reaches CSS. Running this in CI with process.exit(1) on failure means a bad font breaks the build loudly rather than shipping a silent misalignment.
CSS Implementation & Cap Unit Fallbacks
Apply the native 1cap unit where supported for precise uppercase alignment: padding-top: 1cap;. The cap unit equals the cap-height of the current font at the current size.
Calculate a legacy multiplier for unsupported browsers: --cap-ratio: 0.72; padding-top: calc(var(--cap-ratio) * 1em);.
Use @supports (line-height: 1cap) for progressive enhancement, ensuring graceful degradation without layout jumps.
:root {
--cap-ratio: 0.718;
}
/* Use 1cap where supported; fall back to calculated em multiplier */
@supports (font-size: 1cap) {
.icon-aligned { padding-top: 1cap; }
}
@supports not (font-size: 1cap) {
.icon-aligned { padding-top: calc(var(--cap-ratio) * 1em); }
}
Progressive enhancement pattern using the native cap unit with a calculated fallback multiplier.
Vertical Rhythm & Type Scale Integration
Map the calculated cap-height ratio to modular grid steps: gap: calc(var(--cap-ratio) * 1.5em);. This locks spacing to typographic anatomy rather than arbitrary pixel values.
Apply clamp() for fluid scaling across breakpoints while maintaining proportional rhythm.
Sync the opsz variable axis to maintain proportional cap-height at smaller sizes, preventing optical distortion in dense UI text.
/* Metric-matched fallback to eliminate CLS */
@font-face {
font-family: 'Fallback';
src: local('Arial');
size-adjust: 102%;
ascent-override: 98%;
descent-override: 22%;
line-gap-override: 0%;
}
Overrides fallback font metrics to match primary font cap-height and prevent CLS during font loading.
Verification
Confirm the ratio is correct and that it actually stabilizes layout — do not trust the number on faith:
- Spot-check the math in the console. Run the extraction script and compare
capRatioagainst a known reference. For Arial it is ≈0.716; for Helvetica ≈0.718; for Georgia ≈0.692. A value wildly outside 0.6–0.8 for a normal text face signals aunitsPerEmdivisor error. - Box-model overlay. In DevTools → Elements, select a heading and read the box-model overlay. The visible cap top should align with the element you positioned using
1cap(orcalc(var(--cap-ratio) * 1em)) to within a pixel at multiple zoom levels. - CLS measurement under throttling. Open the Performance panel, enable Rendering → Layout Shift Regions, throttle the network to Slow 3G, and reload. With a correct metric-matched fallback the highlighted shift regions over your text should disappear and the recorded CLS should sit well under the 0.1 target.
- PerformanceObserver check. Register a
PerformanceObserverforlayout-shiftentries and assert that no entry'ssourcesreference your text containers after the font swap — a clean log is proof the cap-height match held in the field.
Common Pitfalls
- Assuming
font-sizeequals cap-height. Actual glyph bounds vary significantly — cap-height is typically 65–75% offont-sizedepending on the typeface, so any alignment math that treats them as equal is off by a quarter of the em. - Ignoring
unitsPerEmdiscrepancies between font families. A 2048-unit TrueType face and a 1000-unit CFF face report cap-heights an order of magnitude apart; divide each by its ownunitsPerEmor vertical spacing drifts across mixed-typeface components. - Treating
sCapHeight: 0as a real cap-height. A literal zero is almost always an undeclared field, not a font without capitals — fall through to theHglyph bounds instead of feeding0into CSS. - Using viewport units (
vh/vw) for cap-height calculations. They break accessibility zoom and print layouts; keep cap math inem/capunits that scale with the user's font size. - Skipping
size-adjustin fallback stacks. Matching cap-height in CSS but leaving the fallback@font-faceunadjusted reintroduces measurable layout shifts during the asynchronous swap.
FAQ
Q: How do I calculate cap-height when the font lacks an sCapHeight table entry?
A: Measure the bounding box of a dominant uppercase glyph ('H') from the font's glyph data. With opentype.js: font.charToGlyph('H').getMetrics().yMax / font.unitsPerEm. With fonttools: inspect the glyf table for glyph 'H' and read its yMax coordinate, then divide by unitsPerEm.
Q: Does the CSS 1cap unit work reliably across modern browsers?
A: Supported in Chromium 117+, Safari 16.4+, and Firefox 120+. Implement @supports feature queries with a calculated em multiplier fallback for older browsers.
Q: How does incorrect cap-height calculation trigger Cumulative Layout Shift (CLS)?
A: Mismatched cap-height between primary and fallback fonts alters the computed line-box height when the primary font loads. The browser recalculates line spacing, shifting subsequent DOM nodes. Lock the fallback's effective cap-height using size-adjust and ascent-override on the fallback @font-face block.