Using Capsize to Crop Font Metrics
This guide is part of the Font Metrics & Baseline Alignment area, itself under Typography Fundamentals & System Architecture.
Every font ships with far more vertical space in its line box than the height of the glyphs it draws. Open a heading set in Inter at font-size: 48px with the browser default line-height, and the box a designer draws in Figma around the capital letters never matches the box the browser actually renders — there's air above the caps and air below the baseline that comes straight from the font's OS/2 and hhea tables, not from any CSS you wrote. That gap is why type that looks perfectly aligned in a design tool drifts by 4–12px once it hits a browser, and why vertical rhythm built on line-height alone never quite lines up against a grid. Capsize is a small JavaScript library, maintained by SEEK, that reads a font's real metrics and computes exact CSS trims so the rendered text box matches the visible cap-height and baseline — no more eyeballing negative margins.
Prerequisites
Before wiring Capsize into a project you should already understand two adjacent concepts: how font metrics and baseline alignment work in general (ascent, descent, line-gap, and how they differ across fonts), and how to calculate cap-height for web typography by hand from the unitsPerEm and sCapHeight fields. Capsize automates exactly that calculation, but if you don't understand what it's automating you'll misapply the output the first time a designer asks "why does the heading still look 2px off." You should also have a working type scale or set of font sizes already defined, since Capsize's trims are computed per font-size and need to be regenerated whenever a scale step changes.
Capsize needs the font's metrics, not the font file itself, at build time. Two ways to get them: use the @capsize/metrics package, which ships pre-extracted metrics for hundreds of common web fonts (Inter, Roboto, Georgia, Arial, and the rest of the system stack), or extract metrics yourself from a .woff2/.ttf with fontkit if you're using a custom or licensed font that isn't in the bundled set.
Implementation
Install the core package plus the metrics package for your font:
npm install @capsize/core @capsize/metrics
The primary API is createStyleObject, which takes a font size, a leading (equivalent to line-height in absolute px, not a unitless ratio), and the font's metrics, and returns a plain style object with the trims already computed as negative margins on pseudo-elements:
import { createStyleObject } from '@capsize/core';
import interMetrics from '@capsize/metrics/inter';
const capsizeStyles = createStyleObject({
fontSize: 48,
leading: 56,
fontMetrics: interMetrics,
});
// capsizeStyles now looks roughly like:
// {
// fontSize: '48px',
// lineHeight: '56px',
// '::before': {
// content: "''",
// marginBottom: '-0.098em',
// display: 'table',
// },
// '::after': {
// content: "''",
// marginTop: '-0.219em',
// display: 'table',
// },
// }
Apply that object with whatever styling system you use — inline style in React, a CSS-in-JS css() call, or by hand-copying the trim values into a stylesheet. The display: table on the pseudo-elements is load-bearing: it forces the browser to collapse the pseudo-element's own line box down to a single line so the negative margin actually removes vertical space instead of just overlapping content. Every heading and body-text component that shares this font size and leading can reuse the same computed trims, so in practice teams generate the values once per type scale step and store them as CSS custom properties or design tokens rather than calling createStyleObject at render time.
For a plain-CSS project without a JS build step, you can precompute the trims once and hand-write them:
.heading-xl {
font-size: 48px;
line-height: 56px;
}
.heading-xl::before,
.heading-xl::after {
content: '';
display: table;
}
.heading-xl::before {
margin-bottom: -0.098em;
}
.heading-xl::after {
margin-top: -0.219em;
}
The em unit on the margins matters: because em is relative to the element's own font-size, the same trim ratio holds even if you later change the font size at a breakpoint with clamp() as described in Fluid Type Scale with clamp() — you don't need to regenerate a new trim for every viewport width, only for genuinely different font-size/leading pairs.
A capHeightTrim / lineGap variant for tighter control
The default createStyleObject trims to the font's ascent and descent, which is usually what you want for body copy that needs descenders (g, y, p) to render fully. For display headings set in all-caps or for numerals where you know no descender glyphs will appear, Capsize exposes a capHeightTrim mode via precomputeValues, which trims to cap-height instead of the full ascent, shaving off the small sliver of space the font reserves for ascenders like h or b that overshoot the cap line:
import { precomputeValues } from '@capsize/core';
import interMetrics from '@capsize/metrics/inter';
const metrics = precomputeValues({
fontSize: 32,
leading: 32,
fontMetrics: interMetrics,
});
// Guard: only use capHeightTrim styling on content you control.
// If this component ever receives user-supplied text with
// descenders, fall back to the standard ascent/descent trim
// so glyphs like "g" or "y" don't get visually clipped by
// a sibling element positioned against the trimmed box.
const hasKnownDescenders = /[gjpqy]/i.test(String(children ?? ''));
const style = hasKnownDescenders
? createStyleObject({ fontSize: 32, leading: 32, fontMetrics: interMetrics })
: {
fontSize: `${metrics.fontSize}px`,
lineHeight: `${metrics.lineHeight}px`,
// capHeightTrim values applied only when safe
};
This defensive branch matters because cap-height trimming is a visual, not a semantic, decision — it assumes you know in advance that the rendered string has no descenders. A heading component that's fed arbitrary CMS content should not blindly assume that; the guard above falls back to the safe ascent/descent trim whenever a descender character is present, trading a fraction of a pixel of extra whitespace for correctness.
Verification
After applying Capsize styles, verify the crop with the browser's box model tooling rather than trusting the numbers alone. In Chrome DevTools, select the trimmed element, open the Computed panel, and check the rendered height of the element against the cap-height you expect: for a 48px Inter heading, the visible glyph height (cap to baseline) should be close to 48 * 0.727 (Inter's capHeight ratio), roughly 35px, and the element's own box — not its line box — should now hug that region tightly instead of the full ~56px line-height box. You can also drop a 1px-tall guide <div> at the exact baseline position and confirm text sits flush against it, which is the same technique used to validate a baseline grid built with CSS Grid. If the crop looks off by a font-loading swap, remember that fallback text rendered before the web font loads has different metrics entirely — trims computed for one font do not carry over to another, which is the same reasoning behind calculating size-adjust for a system-ui fallback.
Building a reusable trim token pipeline
The most durable way to use Capsize in a production design system is to run it once, offline, for every font/size/leading triple your scale defines, and commit the output as static tokens rather than importing @capsize/core into the client bundle at all. A small Node script does this well:
// scripts/generate-capsize-tokens.js
import { precomputeValues } from '@capsize/core';
import interMetrics from '@capsize/metrics/inter';
import fs from 'node:fs';
const scale = [
{ name: 'body', fontSize: 16, leading: 24 },
{ name: 'lead', fontSize: 20, leading: 28 },
{ name: 'h3', fontSize: 24, leading: 32 },
{ name: 'h2', fontSize: 32, leading: 40 },
{ name: 'h1', fontSize: 48, leading: 56 },
];
const tokens = Object.fromEntries(
scale.map(({ name, fontSize, leading }) => {
const v = precomputeValues({ fontSize, leading, fontMetrics: interMetrics });
return [name, { fontSize: v.fontSize, lineHeight: v.lineHeight, capHeightTrim: v.capHeightTrim, baselineTrim: v.baselineTrim }];
})
);
fs.writeFileSync('src/tokens/capsize.json', JSON.stringify(tokens, null, 2));
This keeps the trim math out of the runtime bundle entirely, ties it to your version-controlled scale, and forces a diff review whenever someone bumps a font size — the same way unicode-range subsets should be regenerated in CI rather than by hand, as covered in Automating Font Subsetting in CI/CD Pipelines.
Common Pitfalls
- Applying Capsize trims to inline elements. The
::before/::afterdisplay: tabletrick only collapses vertical space correctly on block or inline-block boxes; on a plain inline element the negative margins can produce inconsistent results across browsers. Setdisplay: inline-blockordisplay: blockon any element you trim. - Forgetting to regenerate trims after a font swap. Trims are metric-specific to one font file. Swapping Inter for a similar-looking alternative without rerunning
createStyleObjectsilently reintroduces 3–8px of unaccounted whitespace, because the new font'ssTypoAscender/sTypoDescendervalues differ even at the same pixel size. - Mixing capHeightTrim with user-generated text. As shown in the defensive variant above, trimming to cap-height on a string that turns out to contain a descender (
g,j,p,q,y) can visually clip that glyph against a tightly-positioned sibling. ReservecapHeightTrimfor content you control, such as all-caps labels or numerals. - Not accounting for the fallback font during the loading gap. A trim computed for the web font applies incorrectly to the fallback glyphs shown before the font loads, which can make the FOUT period look more cramped or clipped than intended — pair Capsize trims with a properly designed accessible fallback stack so the two states are each individually correct.
- Treating the trim as a substitute for
line-heighttuning. Capsize removes the unwanted whitespace the font reserves; it does not replace deliberateline-heightchoices for readability. Keep using a sensible line-height (per Line Height & Vertical Rhythm) and apply Capsize on top of it, not instead of it.
Why the browser box model leaves this gap in the first place
It helps to understand where the extra space actually comes from, because it explains why no amount of line-height: 1 tuning alone removes it. When a browser lays out a line of text, it computes the line box height from three font-level metrics stored in the font binary: ascent (distance from baseline to the top of the tallest glyph the font anticipates, usually taller than any capital letter to leave room for accented characters and diacritics), descent (distance from baseline down to the lowest glyph, covering descenders like g and j), and lineGap (extra breathing room the font's designer baked in for readability at body sizes). Add those three together and you get the font's intrinsic line height at 1 unit of font-size; browsers then also apply any explicit CSS line-height on top of or in place of that value, and split whichever number wins into "half-leading" above and below the glyph area.
None of ascent, descent, or lineGap correspond to where a capital letter's top pixel or a baseline actually sits — they are deliberately generous so that mixing scripts, diacritics, and different fonts in a line doesn't clip anything. That generosity is exactly the "unused whitespace" visualized in the anatomy diagram above, and it is also why two fonts set at the identical font-size and line-height can visually look like different sizes: their ascent/descent ratios differ even when their cap-heights are similar. Capsize sidesteps the problem entirely by ignoring the font's own lineGap and self-reported ascent/descent and instead computing the trim directly from unitsPerEm, capHeight, ascent, and descent read straight out of the hhea/OS/2/head tables, then expressing the result as negative margins scaled to the element's own font-size via em units — which is why the CSS output stays correct across clamp()-driven fluid resizing without regeneration.
Framework and design-tool integration notes
Because Capsize's core API is plain JavaScript that returns a style object, it drops into any styling approach without friction. In a component library using CSS-in-JS (Emotion, styled-components, vanilla-extract), spread the returned object directly into the css call. In a Tailwind-based project, generate the trim values once with the token pipeline shown above and expose them as arbitrary-value utilities or as entries in tailwind.config.js's fontSize scale, which accepts a [fontSize, { lineHeight, letterSpacing }] tuple — though you'll still need the pseudo-element trims applied via a small @layer components rule since Tailwind's fontSize scale has no hook for margin-based trims. Design tools like Figma expose "cap height" and "baseline" as first-class alignment guides already, which is precisely the mismatch Capsize is closing: a designer aligning a box to cap-height in Figma is aligning to the same metric Capsize trims to in the browser, so once trims are applied, redlines measured against cap-height in the design file should match the rendered DOM within a pixel, modulo sub-pixel font rendering differences between the OS text rasterizer and the browser's own font rasterizer.
Frequently Asked Questions
Does Capsize change the font file or the CSS line-height I already set?
No. Capsize does not touch the font file at all — it only reads its metrics table and outputs CSS (a line-height value plus negative margins on generated pseudo-elements). Your original font-size stays exactly as you set it; Capsize's lineHeight output replaces whatever line-height you were using for that specific font/size pairing, and the pseudo-element margins do the actual visual crop.
Can I use Capsize with a variable font whose metrics change across the weight axis?
Font metrics tables (OS/2, hhea) are typically static per font file regardless of variable axis position, so a single Capsize computation usually holds across the full wght range of one variable font. If you're also tuning variable font axes like wght, wdth, and slnt and notice visible drift at extreme weights, verify with the same DevTools box-model check described above rather than assuming the trim is universally correct — some foundries do ship slightly different vertical metrics for named instances.
What happens if the font I need isn't in @capsize/metrics?
Extract the metrics yourself. Capsize's underlying fontkit-based extraction reads unitsPerEm, ascent, descent, lineGap, and capHeight directly from the font binary, and you can pass that object as fontMetrics to createStyleObject in place of a bundled import — the same fields you'd read manually when calculating cap-height for web typography.
Is the visual difference from Capsize actually worth the added markup? For a design system that ties type to a strict grid, yes — a 5–10px unaccounted gap above every heading compounds across a page and is one of the more common sources of "this doesn't match Figma" bug reports. For a single marketing page with no grid requirement, the added pseudo-elements are unnecessary complexity; reserve Capsize for components inside a token-driven type system.