Variable fonts, and when they are worth it
A variable font holds a whole design space in one file. It is bigger than one static weight and smaller than three, so the break-even is around two — and optical size is the axis worth having.
A variable font is one file that carries a family's worth of design. It stores a single set of outlines plus instructions for deforming them, declares a set of named axes, and lets the renderer ask for any position along those axes. What comes back is an instance interpolated at that point — not a separate font that was sitting in the file waiting.
The machinery lives in the font's fvar table. Each axis has a four-letter tag, a minimum, a maximum and a default. Ask for wght 400 and you get the outlines as drawn; ask for 650 and every point moves along the deltas the designer supplied. This is why "a font that resizes" is the wrong summary — every font resizes. A variable font varies design, which is a different thing.
The five axes everything else is built around
The OpenType specification registers five design-variation axes. Registration matters because it standardises the numeric scale: a wght of 400 means Regular in any font from any foundry, which is what lets CSS map a high-level property onto an axis at all.
| Tag | Name | Valid range | "Regular" value | CSS property |
|---|---|---|---|---|
wght |
Weight | 1 to 1000 | 400 (required) | font-weight |
wdth |
Width | greater than 0, as % of normal | 100 (required) | font-stretch |
slnt |
Slant | greater than −90, less than +90 degrees | 0 (required) | font-style: oblique <angle> |
ital |
Italic | 0 to 1 | 0 | font-style: italic |
opsz |
Optical size | greater than 0, in typographic points | 10–16 recommended | font-optical-sizing |
Two details catch people out. First, slnt is measured in counter-clockwise degrees, so a normal right-leaning oblique has a negative slant value — the specification says so explicitly, to match the italicAngle field in the post table.
Second, ital and slnt are alternatives, not a pair. A slant axis shears the upright design; an italic axis swaps in different letterforms — a single-storey a, a cursive e. The specification says fonts should rarely use both.
Case is load-bearing too. Registered tags are lowercase; foundry-defined axes must begin with an uppercase letter. GRAD, XOPQ and YTLC in Roboto Flex are custom axes, and you can tell at a glance.
Optical size is the axis that actually improves a page
Optical size is the one most people have never set, and the only one that improves ordinary body text without anyone choosing anything. It varies stroke weight, glyph proportion and fine detail to suit the displayed size: sturdier stems and more open counters when small, finer hairlines and tighter fitting when large.
Its scale is text size in typographic points, which gives it far stronger interoperability than the other axes — two fonts set to opsz 20 are both meant for 20-point text. That is precise enough for the browser to select a value on its own, and it does.
font-optical-sizing: auto is the initial value, and it has been Baseline widely available since March 2020. If the font has an opsz axis, the browser is already tracking your font-size with it. The property exists mainly so you can write none and stop it.Ranges from families that ship it: Source Serif 4 runs opsz 8 to 60, Literata 7 to 72, Newsreader 6 to 72. All three default to 14 or 16 — the text sizes the designs were drawn for.
Override it only where the automatic choice is wrong, which in practice means settings where rendered size and perceived size diverge:
.hero {
font-size: 4rem;
font-variation-settings: 'opsz' 60;
}
.fine-print {
font-optical-sizing: none; /* only if auto is visibly hurting */
}
Do the arithmetic before assuming it saves bytes
A variable font is bigger than one static weight and smaller than four. Where the crossover falls depends entirely on the font, so it is worth a minute with a file listing rather than a rule of thumb.
The full sum:
| Weights needed | Static total | Variable | Difference |
|---|---|---|---|
| 1 (400) | 23.1 KB | 47.1 KB | variable costs 2.0× |
| 2 (400, 700) | 46.9 KB | 47.1 KB | variable costs 236 bytes |
| 3 (400, 600, 700) | 70.8 KB | 47.1 KB | variable saves 33% |
| 4 (400, 500, 600, 700) | 94.5 KB | 47.1 KB | variable saves 50% |
The break-even for this family is almost exactly two weights. At three it is a clear win; at one it is a doubling for nothing.
Two things shift the line. Italic is normally a second variable file — Inter's is 50.6 KB — so a roman-plus-italic design is two of these sums, not one. And subsetting changes the ratio, because delta data does not shrink at the same rate as outlines. Do the sum on your own files, after subsetting.
Prefer font-weight, and reach for font-variation-settings last
Declare the axis ranges in @font-face and then use ordinary properties. The range syntax in font-weight is what tells the browser this one file covers 200 through 900:
@font-face {
font-family: 'Source Serif 4';
src: url('/fonts/source-serif-4.subset.woff2') format('woff2');
font-weight: 200 900;
font-style: normal;
font-display: swap;
}
h1 { font-weight: 720; } /* any value in range, not just steps of 100 */
.lead { font-weight: 350; }
format('woff2-variations') is a transitional string you no longer need — Google Fonts serves its own variable files as plain format('woff2').
There are three reasons to keep font-variation-settings for custom axes only, and none of them are stylistic.
It wins the cascade unconditionally. A value set there overrides the equivalent basic property wherever the two appear in the cascade, so one stray declaration inside a component defeats every font-weight rule that should have beaten it.
It is one value, not a set of sub-properties. A rule setting 'opsz' 32 replaces an inherited 'wght' 700 rather than adding to it, and the weight snaps back to the axis default with no warning. Every axis has to be repeated in every rule that touches any of them.
And it does not survive a fallback. If the font fails to load, font-weight: 700 still produces a bold from the fallback face; font-variation-settings: 'wght' 700 means nothing to a font with no wght axis, and the text renders Regular.
Loading it
font-display: swap for display type and optional for body text, exactly as with a static face. Preload is where a variable font is genuinely easier — one file rather than four, so the advice to preload only the face in the first screenful stops being a painful choice.
<link rel="preload" href="/fonts/source-serif-4.subset.woff2" as="font" type="font/woff2" crossorigin>
crossorigin is required even for a same-origin font, or the browser fetches the file twice.
Animating the weight axis is usually a mistake
It works, and it is the first thing everyone tries. font-variation-settings is animatable, transitions interpolate it, and a heading that swells from Light to Black on hover demos well.
The trouble is mechanical. Changing weight changes glyph advance widths, so the line measures differently every frame and the browser re-breaks the text underneath you. The glyphs are re-rasterised each frame too — this is not transform, which a compositor handles without touching layout. On a slow device the cost is visible, and whether the effect earned it is a judgement call at best.
If you do it, contain it. Animate one short word inside a box with a reserved width so nothing reflows, keep the range narrow, and honour the user's setting:
.mark {
display: inline-block;
min-width: 6ch; /* reserve the widest state */
font-variation-settings: 'wght' 400;
transition: font-variation-settings 180ms ease;
}
.mark:hover { font-variation-settings: 'wght' 600; }
@media (prefers-reduced-motion: reduce) {
.mark { transition: none; }
}
What a variable font cannot give you
A variable font is one designer's interpolation. The masters are drawn at the ends, with whatever intermediate masters were needed to keep the middle honest, and everything between those points is arithmetic the designer approved rather than shapes they cut. A weight of 550 is a weight the designer permitted — usually fine, and not the same claim as a weight they drew.
The harder limit sits upstream of all of it: a face with one weight has nothing to interpolate. There is no design space, no second master, no deltas — there is one set of outlines, and a variable build of it would be the same file with an empty axis.
That is the case for every typeface we make. Typefound's eight faces are each a single weight with no italic; Drip, the face setting this page, is 105 characters and one master. None of them are variable, and none of them could be.
It also explains a decision on this site that looks odd until you know why. Ask a browser for bold on a single-weight font and it will not refuse — it invents one by smearing the glyph sideways, which on a face with sharp terminals reads as a printing fault. So the poster tool grows the real outline with -webkit-text-stroke rather than asking for a weight that does not exist.
Variable fonts are an excellent delivery format for a family that already has range. They do not create range. The question to settle first is not whether to ship a variable file — it is how many weights the design genuinely has, and whether the page needs more than two of them.