
How to use variable fonts in CSS?
- Sajjad
- Typography
- 11 Oct, 2026
To use a variable font in CSS, declare it once with @font-face and give each axis a range rather than a single value, for example font-weight: 100 900, font-stretch: 75% 125% or font-style: oblique 0deg 12deg. You then control the registered axes with the normal properties you already know: font-weight for weight, font-stretch for width, font-style for italic and slant, and font-optical-sizing for optical size. Custom axes, and any fine control the standard properties don't offer, go through the low-level font-variation-settings property, which takes four-letter axis tags such as "GRAD" 50. Because axis values are continuous, you can use any number in the range, respond to media queries, and animate between values with transitions.
Getting the declaration right matters. If you forget the range in @font-face, the browser treats the font as a single weight and either synthesises bold or ignores your values entirely. Misusing font-variation-settings can also quietly break inheritance across your stylesheet. In this article you'll learn how to declare variable fonts correctly, how to use each axis, how to manage custom axes cleanly with custom properties, how to load variable fonts from Google Fonts, and how to animate them responsibly.
Step 1: Declare the Font With Axis Ranges
A variable font needs a @font-face rule that tells the browser which part of each axis the file covers.
@font-face {
font-family: "Roboto Flex";
src: url("/fonts/roboto-flex-var.woff2") format("woff2");
font-weight: 100 1000;
font-stretch: 25% 151%;
font-style: normal;
font-display: swap;
}
Each descriptor works as follows:
font-weight: 100 1000: The file covers weights from 100 to 1000. When an element requests weight 650, the browser uses this face and sets thewghtaxis to 650.font-stretch: 25% 151%: The file covers widths from 25% to 151%. Requests forfont-stretch: 85%map to thewdthaxis.font-style: normal: This file is the upright face.format("woff2"): The plainwoff2format is correct for variable fonts in all modern browsers.
The ranges should match the axis ranges in the font file. If you declare a narrower range, values outside it are clamped. If you declare a single value such as font-weight: 400, the browser assumes the face only provides that weight and will synthesise bold for font-weight: 700 instead of using the axis.
Adding the Italic File
Most variable families ship italics as a separate file. Declare it with the same family name and a different style:
@font-face {
font-family: "Source Serif";
src: url("/fonts/source-serif-4-roman-var.woff2") format("woff2");
font-weight: 200 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Source Serif";
src: url("/fonts/source-serif-4-italic-var.woff2") format("woff2");
font-weight: 200 900;
font-style: italic;
font-display: swap;
}
Now em and font-style: italic select the italic file automatically, at any weight.
Fonts With a Slant Axis
Some fonts have a slnt axis instead of a separate italic. Declare the range with oblique and angles:
@font-face {
font-family: "Recursive";
src: url("/fonts/recursive-var.woff2") format("woff2");
font-weight: 300 1000;
font-style: oblique 0deg 15deg;
font-display: swap;
}
.note {
font-family: "Recursive", sans-serif;
font-style: oblique 10deg;
}
Note that the slnt axis in OpenType uses negative values for a forward slant, while CSS oblique angles are positive for a forward slant. The browser handles the conversion, so you write positive angles in CSS.
Step 2: Use the Registered Axes With Standard Properties
Once declared, the font behaves like any family, except that every value in the range is available.
body {
font-family: "Roboto Flex", Arial, sans-serif;
font-weight: 400;
}
h1 {
font-weight: 780;
font-stretch: 88%;
}
h2 {
font-weight: 620;
}
.label {
font-weight: 540;
font-stretch: 110%;
letter-spacing: 0.02em;
}
strong {
font-weight: 680;
}
Use the standard properties wherever possible. They inherit normally, work with keyword values like bold and bolder, and give the browser the information it needs to pick the right face and fall back sensibly.
Optical Size
If the font has an opsz axis, browsers set it automatically to match the font size in CSS pixels, because font-optical-sizing defaults to auto. You rarely need to do anything. To turn it off:
.fixed-optical-size {
font-optical-sizing: none;
}
Keyword Values Still Work
Keywords map to numbers, so font-weight: bold means 700 and font-stretch: condensed means 75%. Relative keywords bolder and lighter step through fixed weight thresholds defined in the CSS spec rather than adding a fixed amount, so set explicit numbers when you want precise control.
Step 3: Control Custom Axes With font-variation-settings
Custom axes, written with uppercase tags, have no dedicated property. Use font-variation-settings:
.card-title {
font-variation-settings: "GRAD" 50, "XTRA" 480;
}
The property takes a comma-separated list of quoted tags and numbers. Tags are case-sensitive.
The Inheritance Trap
font-variation-settings is a single property. Any declaration replaces the whole list, so axes you set higher up are lost:
body {
font-variation-settings: "GRAD" 0, "XOPQ" 96;
}
.highlight {
/* XOPQ is reset to the font's default here */
font-variation-settings: "GRAD" 100;
}
The common fix is to store each axis in a custom property and declare the full list on every element that may change, using a shared selector:
:root {
--grad: 0;
--xopq: 96;
}
body,
.highlight,
.card-title {
font-variation-settings: "GRAD" var(--grad), "XOPQ" var(--xopq);
}
.highlight {
--grad: 100;
}
The declaration has to be repeated on those elements because var() is resolved where the declaration lives. If you only declared it on body, the child would inherit the already computed list, and changing --grad on .highlight would have no effect.
Now each listed element recomputes the setting from its own custom properties, and overriding one axis leaves the others intact.
Avoid It for Registered Axes
You can write font-variation-settings: "wght" 650, but don't. It bypasses font-weight, so the browser doesn't know the text is meant to be bold, fallback fonts won't get bolder, and font-weight declarations elsewhere stop having any effect on that element. Use font-weight, font-stretch and font-style for registered axes and keep font-variation-settings for custom ones.
Step 4: Load Variable Fonts From Google Fonts
The Google Fonts CSS2 API serves variable fonts when you request a range with two dots between the values:
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap">
Multiple axes are listed in alphabetical order with lowercase axes first:
<link rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Roboto+Flex:opsz,wght@8..144,100..1000&display=swap">
To include italics, add the ital axis:
<link rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,opsz,wght@0,8..60,200..900;1,8..60,200..900&display=swap">
If you request single weights such as wght@400;700, Google serves files that may be cut to those instances, so you lose the in-between values. Ask for the range if you plan to use it.
Step 5: Make Typography Responsive
Continuous axes pair well with media queries and container queries.
.article h1 {
font-size: clamp(2rem, 1.4rem + 3vw, 3.5rem);
font-weight: 700;
font-stretch: 100%;
}
@container (max-width: 30rem) {
.article h1 {
font-weight: 740;
font-stretch: 85%;
}
}
Here headings become slightly condensed in narrow containers so long words fit without breaking, and slightly heavier so they hold their presence at a smaller size.
A dark mode grade adjustment is another common case, for fonts with a GRAD axis:
:root {
--grad: 0;
}
@media (prefers-color-scheme: dark) {
:root {
--grad: -25;
}
}
body,
p,
li {
font-variation-settings: "GRAD" var(--grad);
}
Grade changes stroke weight without changing character widths, so nothing reflows when the theme switches.
Step 6: Animate Axes Responsibly
Because the values are numbers, they can be transitioned:
.button {
font-weight: 500;
transition: font-weight 200ms ease-out;
}
.button:hover,
.button:focus-visible {
font-weight: 650;
}
@media (prefers-reduced-motion: reduce) {
.button {
transition: none;
}
}
Keep two things in mind:
- Weight changes width: In most fonts, bolder text is wider. Animating weight inside a button can shift its neighbours. Give the element a fixed or minimum width, or use a grade axis if the font has one, since grade doesn't change width.
- Animation costs layout work: Changing font axes triggers text layout and repaint. Animating many elements at once can feel sluggish on low-powered devices.
You can also animate custom axes with keyframes, provided you register the custom property so it interpolates:
@property --casl {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
.wobble {
font-family: "Recursive", sans-serif;
font-variation-settings: "CASL" var(--casl);
animation: casual 2s ease-in-out infinite alternate;
}
@keyframes casual {
to {
--casl: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.wobble {
animation: none;
}
}
Step 7: Check What the Browser Is Doing
DevTools helps you confirm that your axes are applied.
- Firefox: Inspect an element and open the Fonts tab. It lists the font in use and shows sliders for every axis, which you can drag to experiment live.
- Chrome and Edge: In the Computed pane, scroll to Rendered Fonts to see which file is used. If it shows a fallback, your
@font-facerule or URL is wrong. - Check for synthetic bold: If bold text looks smeared or slightly blurry, the browser is probably synthesising it. Check that your
font-weightrange is declared.
To prevent synthetic styles entirely while testing, use:
body {
font-synthesis: none;
}
If text then fails to appear bold or italic, you know the real face isn't being matched.
Common Mistakes
- Single weight in
@font-face: Declaringfont-weight: 400for a variable file blocks access to other weights. - Using
font-variation-settingsfor weight: It overridesfont-weightand breaks inheritance and fallbacks. - Forgetting the italic file: Without it, browsers create a slanted fake italic.
- Requesting fixed weights from Google Fonts: Use the
..range syntax to get the full axis. - Ignoring fallback metrics: The fallback font still shows before the variable font loads, so tune it to avoid layout shift.
For background on what variable fonts are and when they're worth it, see what are variable fonts, and for the related optical size axis, see optical sizes in fonts.
FAQ: Variable Fonts in CSS
Most likely your font-face rule declares a single weight instead of a range, or you're loading static instances from a font service. Declare font-weight as a range such as 100 900 and request the full axis from the provider.
Use font-weight for weight, font-stretch for width and font-style for italic and slant. Reserve font-variation-settings for custom axes such as GRAD that have no dedicated CSS property.
Yes. Transitions and keyframe animations on font-weight interpolate smoothly with a variable font. Bear in mind that weight usually changes width, and disable the animation for users who prefer reduced motion.
WOFF2 is the best choice for the web. Declare it with format woff2 in the src descriptor. It's supported by every modern browser and compresses better than TTF.
Optical size is set automatically from the font size when font-optical-sizing is auto. To set a specific value, use font-optical-sizing none and then font-variation-settings with the opsz tag and a number.
The browser is probably synthesising bold because it doesn't think the face supports that weight. Check that the font-face weight range matches the font file and that the correct file is loading.
Conclusion
Using variable fonts in CSS comes down to two habits. First, declare honest ranges in @font-face for weight, width and slant, with a separate face for italics, so the browser knows the full design space is available. Second, drive registered axes through font-weight, font-stretch, font-style and font-optical-sizing, and reserve font-variation-settings for custom axes, ideally managed through custom properties so one change doesn't wipe out the others.
With that in place, you can tune headings to exact weights, condense text in narrow containers, adjust grade for dark mode, and animate subtly where it helps. Check the result in DevTools, watch out for synthetic bold, and load the full axis range from your font provider so the values you write actually reach the screen.


