Type something to search...
How to use variable fonts in CSS?

How to use variable fonts in CSS?

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 the wght axis to 650.
  • font-stretch: 25% 151%: The file covers widths from 25% to 151%. Requests for font-stretch: 85% map to the wdth axis.
  • font-style: normal: This file is the upright face.
  • format("woff2"): The plain woff2 format 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.

  1. 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.
  2. Chrome and Edge: In the Computed pane, scroll to Rendered Fonts to see which file is used. If it shows a fallback, your @font-face rule or URL is wrong.
  3. Check for synthetic bold: If bold text looks smeared or slightly blurry, the browser is probably synthesising it. Check that your font-weight range 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: Declaring font-weight: 400 for a variable file blocks access to other weights.
  • Using font-variation-settings for weight: It overrides font-weight and 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.

Share :

Related Posts

How to make typography accessible?

How to make typography accessible?

You make typography accessible by choosing clear typefaces, setting text in relative units so it scales with user preferences, giving it enough colou

Dive Deeper
What are the parts of a letterform?

What are the parts of a letterform?

A letterform is the shape of a single letter, and typographers break it down into named parts. The main ones are the stem (the main vertical

Dive Deeper
What are ascenders, descenders and the baseline?

What are ascenders, descenders and the baseline?

The baseline is the invisible line that letters sit on. Ascenders are the parts of lowercase letters that rise above the x-height, such as th

Dive Deeper