
Variable Fonts in CSS: One File, Endless Styles
A traditional font family ships as a collection of separate files: Regular, Italic, Medium, Semibold, Bold, Bold Italic, and so on. Each style your design uses is another download. Variable fonts flip that model around. A single file contains the full design space of the typeface, and you pick any point within it using CSS. Instead of choosing between weight 400 and 700, you can use 400, 537, or 612, and you can animate smoothly between them.
Variable fonts are supported in every modern browser and are available for many popular typefaces, including Inter, Roboto Flex, Recursive, Source Sans, and much of the Google Fonts catalog. This guide covers how they work, how to load them, the CSS properties that control them, and some practical and creative ways to use them.
How Variable Fonts Work
A variable font defines one or more axes of variation. Each axis is a range the design can move along, like thin to black, or condensed to expanded. The type designer draws the extremes, and the font format interpolates everything in between.
Every axis has a four-character tag. Five are registered axes, standardized in the OpenType specification and written in lowercase:
| Axis | Tag | What it controls | CSS property |
|---|---|---|---|
| Weight | wght | Thin to black | font-weight |
| Width | wdth | Condensed to expanded | font-stretch |
| Italic | ital | Upright or italic | font-style: italic |
| Slant | slnt | Oblique angle | font-style: oblique |
| Optical size | opsz | Design tuned for small or large text | font-optical-sizing |
Type designers can also add custom axes, written in uppercase. Examples include GRAD (grade, which changes stroke thickness without changing width), CASL (casualness in Recursive), and MONO (proportional to monospace in Recursive).
A font doesn't have to support every registered axis. Many variable fonts only have wght. Check the font's documentation or a tool like Wakamai Fondue to see which axes and ranges are available.
Loading a Variable Font
The @font-face rule looks almost the same as for a static font. The key difference is that descriptors like font-weight take a range instead of a single value:
@font-face {
font-family: "Inter";
src: url("/fonts/InterVariable.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Inter";
src: url("/fonts/InterVariable-Italic.woff2") format("woff2");
font-weight: 100 900;
font-style: italic;
font-display: swap;
}
The font-weight: 100 900 range tells the browser this one file covers every weight from 100 to 900. Without the range, the browser would treat the file as a single weight and might synthesize fake bold on top of it.
Width works the same way, using percentages:
@font-face {
font-family: "Roboto Flex";
src: url("/fonts/RobotoFlex.woff2") format("woff2");
font-weight: 100 1000;
font-stretch: 25% 151%;
font-display: swap;
}
Some older snippets use format("woff2-variations") or the newer format("woff2") tech(variations) syntax. You don't need either: plain format("woff2") works for variable fonts in every current browser, and it avoids problems in older browsers that don't recognize the tech() function.
From Google Fonts
Google Fonts serves variable versions when you request a range with ..:
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap"
rel="stylesheet"
/>
Multiple axes use comma-separated lists in alphabetical order, for example family=Roboto+Flex:opsz,wdth,wght@8..144,25..151,100..1000.
Controlling Axes with Standard Properties
For registered axes, use the regular CSS properties. They're readable, they cascade and inherit normally, and they work the same whether or not the font is variable.
Weight
h1 {
font-weight: 780;
}
.lead {
font-weight: 350;
}
strong {
font-weight: 650;
}
Any number from 1 to 1000 is valid. The browser clamps it to the range the font supports.
Width
font-stretch takes percentages or keywords like condensed and expanded:
.headline {
font-stretch: 75%;
}
.data-table td {
font-stretch: 87.5%;
}
Newer specifications rename this property to font-width, but font-stretch is the one with broad support today. Keep using font-stretch unless you've confirmed the alias works in your target browsers.
Italic and Slant
em {
font-style: italic;
}
.tilted {
font-style: oblique 8deg;
}
If the font has an ital axis or separate italic file, italic uses real italic letterforms. oblique with an angle maps to the slnt axis when available.
Optical Size
Optical sizing adjusts letterforms for the size they're displayed at: sturdier with more open spacing at small sizes, finer and tighter at display sizes. With a font that has an opsz axis, browsers apply it automatically based on font-size:
body {
font-optical-sizing: auto; /* the default */
}
Set it to none if you want to turn that off.
Low-Level Control with font-variation-settings
For custom axes, or when you need direct control, use font-variation-settings:
.code-sample {
font-family: "Recursive", monospace;
font-variation-settings:
"MONO" 1,
"CASL" 0.5;
}
Each pair is a quoted axis tag and a number. Custom axis tags are case-sensitive, so "GRAD" and "grad" are different axes.
You can set registered axes this way too, but it's usually better not to. The standard properties are clearer and interact correctly with things like <strong> and browser defaults. Reach for font-variation-settings only when there's no higher-level property.
The Inheritance Trap
font-variation-settings has a sharp edge: it's a single property, so setting it replaces the whole value rather than merging with what's inherited.
.card {
font-variation-settings: "GRAD" 50;
}
/* This wipes out GRAD on the link */
.card a:hover {
font-variation-settings: "CASL" 1;
}
The fix is to drive each axis with a custom property and set font-variation-settings in one place:
:root {
--grad: 0;
--casl: 0;
--mono: 0;
}
body {
font-variation-settings:
"GRAD" var(--grad),
"CASL" var(--casl),
"MONO" var(--mono);
}
.card {
--grad: 50;
}
.card a:hover {
--casl: 1;
}
Now each component changes only the axis it cares about, and the others keep their inherited values. Custom properties inherit, but font-variation-settings itself is only declared on body here, so you may need to repeat the declaration on elements that set a different font-family, or apply it with a broader selector like *.
Practical Uses
Fine-Tuned Hierarchy
With static fonts, you pick from a handful of weights. With a variable font, you can tune weight for each level of your type scale and pair it with size:
h1 {
font-size: 3rem;
font-weight: 800;
}
h2 {
font-size: 2.25rem;
font-weight: 720;
}
h3 {
font-size: 1.75rem;
font-weight: 650;
}
h4 {
font-size: 1.25rem;
font-weight: 600;
}
Adjust Weight for Dark Mode
Light text on a dark background tends to look heavier than the same weight in dark on light. A small reduction evens it out:
body {
font-weight: 400;
}
@media (prefers-color-scheme: dark) {
body {
font-weight: 370;
}
}
If the font has a GRAD axis, adjusting grade is even better, because it changes stroke thickness without changing letter widths, so text doesn't reflow.
Responsive Width
Condense headlines on narrow screens so long words fit without shrinking the font size:
.headline {
font-family: "Roboto Flex", sans-serif;
font-size: clamp(2rem, 6vw, 4.5rem);
font-stretch: 100%;
}
@media (max-width: 40em) {
.headline {
font-stretch: 80%;
}
}
Hover Effects Without Layout Shift
Changing font-weight on hover usually makes text wider and nudges surrounding content. Grade avoids that on fonts that support it:
.nav-link {
--grad: 0;
font-variation-settings: "GRAD" var(--grad);
transition: font-variation-settings 200ms ease;
}
.nav-link:hover,
.nav-link:focus-visible {
--grad: 150;
}
Without a GRAD axis, a common trick is to reserve the bold width in advance with a hidden pseudo-element containing the same text, so the hover weight change doesn't move anything.
Animating Variable Fonts
Because axes are continuous, they animate smoothly:
@keyframes breathe {
0%,
100% {
font-weight: 200;
}
50% {
font-weight: 800;
}
}
.hero-word {
font-family: "Inter", sans-serif;
animation: breathe 3s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.hero-word {
animation: none;
}
}
Keep in mind that animating weight or width changes glyph sizes and triggers layout on every frame. It's fine for a single headline, but avoid it on large blocks of text or many elements at once. Always respect prefers-reduced-motion.
Performance: Is One Big File Really Better?
A variable font file is larger than any single static weight. Whether it saves bytes depends on how many styles you use:
- One or two weights: static files may be smaller in total.
- Three or more weights, or italics and widths: a variable font usually wins, and it's one request instead of many.
Either way, the usual font optimizations still apply. Serve WOFF2, subset to the character ranges you need, preload the file that renders above-the-fold text, and choose an appropriate font-display value. Some tools can also instance a variable font, cutting its axes to a narrower range (for example, weight 300 to 700 only), which reduces file size further:
pip install fonttools brotli
fonttools varLib.instancer InterVariable.ttf wght=300:700 -o Inter-300-700.ttf
pyftsubset Inter-300-700.ttf --unicodes="U+0000-00FF" --flavor=woff2 --output-file=inter-latin-300-700.woff2
Fallbacks for Older Browsers
Variable font support is universal in current browsers, so most projects don't need a fallback. If you must support very old browsers, you can serve static files through a feature query:
@font-face {
font-family: "Inter Static";
src: url("/fonts/Inter-Regular.woff2") format("woff2");
font-weight: 400;
}
@font-face {
font-family: "Inter Static";
src: url("/fonts/Inter-Bold.woff2") format("woff2");
font-weight: 700;
}
body {
font-family: "Inter Static", system-ui, sans-serif;
}
@supports (font-variation-settings: normal) {
body {
font-family: "Inter", system-ui, sans-serif;
}
}
Only the fonts actually used for rendering get downloaded, so browsers that support variable fonts won't fetch the static files.
Common Mistakes
- Declaring a single weight in
@font-face. Without a range like100 900, the browser may apply faux bold or pick the wrong file. - Setting registered axes with
font-variation-settingsinstead offont-weightand friends, which fights with inherited styles and browser defaults. - Overwriting
font-variation-settingsin nested components and losing other axes. Use custom properties. - Assuming every font has every axis. Setting
font-stretchon a font with nowdthaxis does nothing, or picks another face if one exists. - Loading the full design space when you use a sliver of it. Instance and subset the font.
Conclusion
Variable fonts turn a typeface from a fixed menu into a continuous range. Load one file with ranged @font-face descriptors, control registered axes with font-weight, font-stretch, font-style, and font-optical-sizing, and use font-variation-settings backed by custom properties for custom axes like grade.
The result is finer typographic control, fewer requests when you use several styles, and new possibilities like width that adapts to the screen, weight that adjusts for dark mode, and hover effects that don't shift the layout. Pick a variable font for your next project and experiment with the axes. You'll likely wonder how you managed with a handful of fixed weights.


