Variable Font Axis Tokens and Fallback Metrics
Part of Typography Scale Systems. A variable font turns weight and width into continuous axes, which is a token problem. A web font that loads after first paint shifts the layout, which is a metrics problem. Both are solvable from the token layer.
Prerequisites Link to this section
- A variable font with published axis ranges; check the font’s documentation for which axes it exposes and their limits.
- The typography sets from tokenizing line-height and letter-spacing pairs, which these tokens extend.
- The font’s vertical metrics — ascent, descent, line gap and units per em — readable from the font file with any font inspection tool.
- A way to measure layout shift; Chrome DevTools or a Playwright assertion both work.
Step-by-Step Implementation Link to this section
Step 1 — Tokenise the axis stops Link to this section
Intent: name the values on each axis that the design system actually uses.
:root {
/* Weight axis — named stops, not arbitrary numbers at the call site. */
--ds-font-weight-body: 400;
--ds-font-weight-medium: 500;
--ds-font-weight-heading: 560;
--ds-font-weight-strong: 700;
/* Width axis — only where the design genuinely varies it. */
--ds-font-width-normal: 100;
--ds-font-width-condensed: 95;
}
Why this works: a variable font makes every value between 100 and 900 available, which is exactly why the stops need names. Without them, one component uses 550 and another 560, the difference is imperceptible individually and visible when they sit adjacent, and no tool can tell that the two were meant to be the same thing.
Step 2 — Prefer standard properties over font-variation-settings Link to this section
Intent: use the high-level property where one exists, because it participates in the cascade and in font fallback correctly.
/* Preferred: the registered axis has a CSS property. */
.heading { font-weight: var(--ds-font-weight-heading); }
.condensed { font-stretch: 95%; }
/* Only for custom axes with no property equivalent. */
.display {
font-variation-settings: "GRAD" var(--ds-font-grade-display);
}
Why this works: font-variation-settings is a low-level escape hatch. It does not inherit per-axis — setting it on a child replaces the whole string rather than merging — and it bypasses the fallback font’s own weight handling. Using font-weight means a fallback font still renders a bold heading; using font-variation-settings: "wght" 700 means it renders at the fallback’s default weight, because the fallback has no wght axis.
Step 3 — Read the real metrics from the font file Link to this section
Intent: metric overrides need the actual numbers, not estimates.
// scripts/font-metrics.mjs
import { readFileSync } from 'node:fs';
import { Font } from 'fonteditor-core'; // or any font parser
const font = Font.create(readFileSync('assets/fonts/Inter.woff2'), { type: 'woff2' });
const { head, hhea, 'OS/2': os2 } = font.get();
const upm = head.unitsPerEm;
console.log({
ascent: `${(os2.sTypoAscender / upm * 100).toFixed(2)}%`,
descent: `${(Math.abs(os2.sTypoDescender) / upm * 100).toFixed(2)}%`,
lineGap: `${(os2.sTypoLineGap / upm * 100).toFixed(2)}%`,
});
Why this works: the override properties take percentages of the em square, so every value has to be divided by units-per-em. Reading them from the file rather than copying from a blog post matters because these numbers differ between versions of the same font — a subsetted or updated file frequently ships different metrics.
Step 4 — Declare an adjusted fallback Link to this section
Intent: make the fallback font occupy the same vertical and horizontal space as the web font.
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
size-adjust: 107%; /* match x-height so text looks the same size */
ascent-override: 90%;
descent-override: 22.5%;
line-gap-override: 0%;
}
:root {
--ds-font-family-sans: "Inter", "Inter Fallback", system-ui, sans-serif;
}
Why this works: size-adjust scales the fallback so its x-height matches the web font’s, which removes the horizontal reflow when the swap happens. The three override properties fix the line box height, which removes the vertical shift. Together they make the swap nearly imperceptible — the glyph shapes change and nothing moves.
Step 5 — Publish the metrics as tokens too Link to this section
Intent: the overrides are part of the type system and should live with it.
{
"font": {
"sans": {
"family": { "$value": "Inter", "$type": "fontFamily" },
"fallback": {
"sizeAdjust": { "$value": "107%", "$type": "dimension" },
"ascentOverride": { "$value": "90%", "$type": "dimension" },
"descentOverride":{ "$value": "22.5%","$type": "dimension" }
}
}
}
}
Why this works: keeping the metrics in the token file means they are regenerated when the font is updated, reviewed alongside every other type change, and available to any platform that needs them. Leaving them in a hand-written stylesheet means they are silently wrong the first time the font file is replaced.
Step 6 — Load the font without blocking Link to this section
Intent: font-display decides what the visitor sees during the swap, and the right value depends on whether metrics are matched.
@font-face {
font-family: "Inter";
src: url("/fonts/Inter.var.woff2") format("woff2-variations");
font-weight: 100 900; /* declare the axis range */
font-display: swap;
unicode-range: U+0000-00FF, U+2000-206F;
}
Why this works: font-display: swap shows the fallback immediately and swaps when the web font arrives, which is the correct choice once metrics are matched — the swap becomes visually minor. Without matched metrics, swap is the value that produces the most visible reflow, and teams often reach for optional instead, which avoids the shift by frequently never loading the font at all.
Verification Link to this section
Measure the shift rather than assuming it. Load the page with the font request blocked, record the position of an element below a block of text, unblock and let the font load, and compare positions.
// tests/font-shift.spec.mjs
import { test, expect } from '@playwright/test';
test('font swap causes no layout shift', async ({ page }) => {
await page.route('**/*.woff2', (route) => route.abort());
await page.goto('/article/');
const before = await page.locator('#below-the-fold').boundingBox();
await page.unroute('**/*.woff2');
await page.reload();
await page.evaluate(() => document.fonts.ready);
const after = await page.locator('#below-the-fold').boundingBox();
expect(Math.abs(after.y - before.y)).toBeLessThan(2);
});
A two-pixel tolerance absorbs rounding; anything larger means the overrides are wrong or missing. This assertion is worth running whenever the font file changes, because a font update is exactly when the metrics silently stop matching.
size-adjust — the common half-measure — fixes the horizontal shift and leaves the vertical one.Troubleshooting Link to this section
| Symptom | Likely cause | Fix |
|---|---|---|
| Text still reflows on font load | Only size-adjust was set |
Add ascent and descent overrides; they fix the vertical dimension |
| The fallback looks noticeably larger or smaller | size-adjust computed from cap height rather than x-height |
Recompute from x-height, which is what perceived size follows |
| A heading renders at normal weight in the fallback | Weight is set via font-variation-settings |
Use font-weight; the fallback has no wght axis |
| Setting one axis reset another | font-variation-settings replaces the whole string |
Set each axis via its own property where one exists |
| Metrics stopped matching after a font update | The new file has different vertical metrics | Regenerate the overrides from the file as part of the update |
Migration Note Link to this section
Adding metric overrides to a site that already ships a web font is a self-contained change with a measurable outcome, which makes it unusually easy to justify. Measure the layout shift before, add the overrides, measure again — the improvement is typically the single largest available reduction in cumulative layout shift for a text-heavy page.
Tokenising the axis stops is a separate and slower change, best done alongside the typography set consolidation rather than on its own. The reason is that the two overlap: a role’s weight belongs in its set, so introducing weight tokens without the sets produces a fourth loose scale to combine by hand, which is the problem the sets exist to solve.
Subsetting, Axis Ranges and What They Cost Link to this section
Two decisions about the font file itself interact with everything above, and both are usually made once and never revisited.
Axis range. A variable font can be subsetted to a narrower axis range — shipping 300 to 700 instead of 100 to 900 — which reduces file size meaningfully. The trade is that any value outside the shipped range clamps silently: a component asking for weight 800 renders at 700, with no warning anywhere. If the range is narrowed, the token stops become the specification for what the range must cover, and a check that every weight token falls inside the shipped range is worth the few lines.
Character subsetting. Reducing the glyph set to the characters the product actually uses is the
larger saving and the riskier one. A subset built from English content renders a fallback glyph the
first time a name with a diacritic appears, and that fallback has different metrics — reintroducing
exactly the shift the overrides were added to remove. Subsetting by Unicode range and declaring the
range in @font-face is the safer form, because the browser then knows which characters the file
covers and requests a second file rather than substituting mid-word.
Both decisions belong with the token file rather than in a build script nobody reads, because both constrain what the type system can express. A weight token outside the shipped axis range is not a token — it is a value that silently becomes a different value, which is the precise failure the type system exists to prevent.
Related Link to this section
- Typography Scale Systems — the parent area and the roles these axes serve
- Tokenizing Line-Height and Letter-Spacing Pairs — the sets that weight tokens belong inside
- Building a Modular Type Scale with Custom Properties — the size scale these metrics keep stable