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.

Variable font axes as token values Three variable font axes — weight, width and optical size — shown as continuous ranges with named token stops along each, rather than as discrete font files. One file, three continuous axes, named stops as tokens wght · 100 to 900 400 body 560 heading 700 strong wdth · 75 to 125 95 condensed 100 normal opsz · 8 to 60 — usually driven automatically by font-size
Axis stops are token values like any other. Naming them is what stops a designer's 560 becoming someone else's 550 three components later.

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.

Layout shift with and without metric overrides Two rendering sequences during a font swap: without overrides the fallback occupies a different height and the content below moves, with overrides the line boxes match and nothing moves. No overrides fallback: line box 22px web font: line box 26px every element below moves 4px per line With overrides fallback: line box 26px web font: line box 26px glyphs change, nothing moves
The shift is caused by the line box height changing, not by the glyphs. Matching the metrics removes the cause rather than hiding the symptom.

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.

Which override fixes which dimension of the shift Three override properties mapped to what each one corrects: size-adjust for apparent size and line length, ascent and descent overrides for line box height, and line-gap override for inter-line spacing. size-adjust matches x-height — fixes apparent size and where lines wrap ascent-override / descent-override matches the line box height — fixes vertical position of everything below line-gap-override usually 0% — most web fonts specify no line gap, and matching that is what you want
Three properties, three separate contributions to the shift. Setting only 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.