Variable fonts
One file containing a continuous design space instead of discrete instances.
Powerful and frequently misused — the most common error is reaching for
font-variation-settings when a standard CSS property already maps to the axis.
1. When to invoke
- Choosing between a variable font and a set of statics.
- Using weight, width, optical size, slant, or italic axes.
- Axes not responding, or rendering incorrectly.
- Animating or transitioning a typographic axis.
- Auditing whether a variable font is actually paying for itself.
2. Required context
- Which axes the font exposes, and their ranges (read the font, don't
assume —
wght 100–900is common but not universal). - How many instances you'd otherwise ship.
- Real byte comparison: variable file vs. the statics it replaces.
- Whether you need continuous values or only named instances.
- Whether the design needs optical sizing.
3. Invariant principles
Use standard CSS properties, not
font-variation-settings, wherever one exists. Registered axes map to real properties:Axis Standard property wghtfont-weightwdthfont-stretch(orfont-width)opszfont-optical-sizingitalfont-style: italicslntfont-style: oblique <angle>font-variation-settingsis a low-level override. It does not inherit predictably per-axis — setting it replaces the whole list, so a child that sets one axis loses every other. It also bypasses the cascade's understanding of weight, breaking things likefont-synthesiscontrol and browser bolding logic. Reserve it for custom (unregistered) axes only.Declare the axis range in
@font-face.font-weight: 100 900on the@font-facerule tells the browser the file covers that span; omit it and the browser may treat the font as a single weight and synthesize faux bold.A variable font is not automatically smaller. It carries the whole design space. Compare real bytes.
Only ship the axes you use. Instancing/subsetting can strip unused axes.
4. Context-dependent heuristics
Variable vs. static — the break-even.
| Instances needed | Usually |
|---|---|
| 1–2 | Static wins |
| 3 | Roughly even — measure |
| 4+ | Variable usually wins |
| Continuous values or animation | Variable, necessarily |
Measure, don't assume. A variable file replacing two weights often costs bytes.
Optical size (opsz) is the underused one. It adjusts the design for the size
it's rendered at — more contrast and tighter spacing at display sizes, sturdier
forms and looser spacing at text sizes. This is what metal type did by cutting each
size separately. Enable with font-optical-sizing: auto (browsers apply it from
font-size automatically when the axis exists). Turn it off only deliberately.
Weight is now continuous. You can use 450 or 530. Resist it in a design system — the value of a scale is exclusion. Pick your named steps and tokenize them.
Grade (GRAD) — the axis most people don't know they need. Grade changes
apparent weight without changing advance widths, so text gets visually heavier
or lighter while occupying exactly the same space. Nothing reflows.
Two uses:
- Dark mode. Light-on-dark text appears optically bloated — the light strokes bleed into the dark ground. Drop the grade slightly in dark mode and the text reads at the same visual weight as its light-mode counterpart. (In print the relationship inverts.) This is the correct fix for "our dark mode looks heavier", far better than switching to a lighter weight, which would reflow the line.
- Interaction states. Because nothing reflows, grade is safe for hover and
active states in a way
font-weightis not.
GRAD is a custom axis (Roboto Flex exposes it), so it is one of the legitimate
cases for font-variation-settings — there is no standard property for it.
Parametric axes go further still, exposing thin-stroke, thick-stroke, counter width, and similar low-level parameters. Powerful for fine-tuning a face to a specific context; easy to produce something the type designer would not recognise. Use sparingly and compare against the designed instances. Custom axes are arbitrary — a type designer can expose almost any parameter of the design this way, down to contrast, x-height, serif shape, or the length of a single glyph's tail (Rutter cites a Q's tail as a real example); the four-character tag is whatever the foundry chose, so read the specimen rather than guessing at what a custom axis controls.
Multiplexed fonts solve the same reflow problem a different way: every weight
occupies identical horizontal space by design, so switching weight never changes
width. See ui-component-typography.
Animation. Axes are animatable, which enables genuinely new effects. Constrain
it: animating wght reflows text if the width changes, and any motion must respect
prefers-reduced-motion.
@media (prefers-reduced-motion: no-preference) {
.headline { transition: font-variation-settings 200ms ease; }
}
Fallbacks. Variable font support is universal in current browsers, but the file
still has to load. The metric-matched-fallback technique in
font-loading-and-performance applies unchanged.
5. Failure patterns
| Pattern | Cause | Fix |
|---|---|---|
| Faux bold despite a variable font | @font-face didn't declare the range |
font-weight: 100 900 in @font-face |
| Child element loses width/optical settings | font-variation-settings replaces the whole list |
Use standard properties |
| Variable font larger than the statics it replaced | Replaced too few instances | Measure; consider statics |
| Axis has no effect | Axis not present, or outside its range | Inspect the font's actual axes |
| Text reflows during a weight animation | Weight change alters advance widths | Animate a non-reflowing property, or reserve space |
| Display text looks flabby at 72px | opsz unused or disabled |
font-optical-sizing: auto |
| Design drifts to arbitrary weights | Continuous axis, no tokens | Tokenize named steps |
| Dark mode text looks heavier than light mode | Optical bloat of light-on-dark | Lower GRAD, not wght — no reflow |
| Hover state shifts the layout | font-weight changes advance widths |
GRAD, or a multiplexed face |
| Parametric tuning produced something odd | Low-level axes pushed past the design space | Compare against the designed instances |
| Motion sickness complaints | Axis animation, no reduced-motion guard | Wrap in the media query |
6. Evaluation procedure
- Inspect the font's actual axes and ranges (browser devtools,
fonttools, or the foundry spec). Don't assume. - Count the instances the design genuinely uses.
- Measure both: variable file vs. sum of the statics, after subsetting.
- Confirm
@font-facedeclares each axis range. - Confirm standard properties are used for registered axes;
font-variation-settingsonly for custom axes. - Check inheritance: set an axis on a parent, verify a child that changes one axis retains the rest.
- If
opszexists, verifyfont-optical-sizing: autoand compare display vs. text rendering. - Confirm no faux bold/italic is being synthesized.
- If animating, verify
prefers-reduced-motionis respected and no reflow occurs.
7. Output format
Font: <name>
Axes available: <wght 100–900, wdth 75–125, opsz 14–72, …>
Axes used: <list> — via <standard property | font-variation-settings + reason>
@font-face ranges declared: <yes/no>
Instances replaced: <n> — variable <n>KB vs statics <n>KB → <win|loss>
Optical sizing: <auto|off> — because <reason>
Tokenized weights: <list>
Faux synthesis: <none|present>
Reduced-motion respected: <yes/n-a>
8. Examples
Design system shipping Inter at 400/500/600/700 as four static files.
Four instances is past the break-even, and the variable file measures smaller than the four statics after subsetting, so switch. Declare
font-weight: 100 900in@font-faceso the browser knows the span and stops synthesizing. Usefont-weight— notfont-variation-settings— so the cascade andfont-synthesiskeep working. Tokenize exactly the four existing steps despite the axis now being continuous; the scale's value is that 530 is unavailable.
Headline set in a family with an opsz axis, looking weak at 64px.
The optical size axis isn't engaged, so the display setting is using text-optimised forms — too little contrast and too much spacing for 64px.
font-optical-sizing: autolets the browser driveopszfrom the rendered size; the headline picks up the higher-contrast, tighter-spaced cut it was designed to have. No markup change, no extra bytes.
9. Counterexamples
- ❌
font-variation-settings: "wght" 700whenfont-weight: 700exists. — Breaks inheritance and cascade behavior. - ❌ "Variable fonts are always smaller." — They carry the full design space; two weights are often cheaper as statics.
- ❌ Omitting
font-weight: 100 900from@font-face. — Invites faux bold. - ❌ "We have a continuous weight axis, so let designers pick any value." — Destroys the system's constraint.
- ❌ Animating
wghton body text without checking reflow or reduced-motion. - ❌ "It has an
opszaxis so it handles sizes automatically." — Only if optical sizing is enabled and not overridden.
10. Source citations
- Current standards take precedence here — variable fonts postdate most of the
corpus. MDN —
variable fonts guide,
font-variation-settings,font-optical-sizing. web.dev — optimize web fonts. - Stocks, Universal Principles of Typography (2024) — the most current book in the corpus on variable fonts. §29 multiple widths; §30 masters and interpolation (weight and width masters combined by vector addition); §31 multiplexed typefaces; §84–85 variable fonts and axes; §86 grade — "grade, unlike weight (except in multiplexed fonts), doesn't affect the overall width of the glyphs", and "on screens, light-on-dark text will appear slightly bloated, so, if possible, switch to a lighter grade when designing for Dark Mode. The opposite is true in print"; §87 parametric axes; §88 the inheritance problem; §27 avoid faux styles.
- Brown, Flexible Typesetting — designing typographic systems that adapt rather than fixed instances; the conceptual case for continuous ranges.
- Rutter, Web Typography — OpenType foundations that variable fonts extend;
custom axes as arbitrary designer-defined parameters (contrast, x-height, serif
shape, individual glyph details) reachable only through
font-variation-settings. His browser-support and format-string details (format('woff-variations'), multiple repeated@font-facerules for fallback, an early-2018 viability estimate) are from 2017 and superseded — variable font support is now universal; don't carry those specifics forward.