Type something to search...
How to Animate Variable Font Axes with CSS?

How to Animate Variable Font Axes with CSS?

The navigation links on a product site thicken smoothly when you hover over them, the hero headline seems to breathe from light to heavy as the page loads, and a section title stretches wider as it scrolls into view. None of it uses images, video, or swapped font files. It is one variable font and a few lines of CSS. The same techniques, applied carelessly, produce menus that jitter as each link changes width, headlines that drop frames on a mid-range phone, and motion that makes some users physically uncomfortable.

This article shows you how to animate variable font axes properly: which properties animate, how to transition and keyframe them, how to animate individual axes independently with @property, how to tie font animation to scroll position, and how to keep it smooth and accessible. It assumes you can already load and control a variable font; if not, start with how to use variable fonts in CSS.

Which Font Properties Can Be Animated?

Variable font axes are numbers, and CSS can interpolate numbers. These are the properties you will animate:

PropertyAxis it drivesAnimates smoothly?
font-weightwghtYes, with a variable font
font-stretchwdthYes, with a variable font
font-style: oblique XdegslntYes, between oblique angles
font-variation-settingsAny axisYes, when both values list the same axes
font-optical-sizingopszNo (keyword); animate "opsz" via variation settings

With a static font, animating font-weight jumps from one file to the next at the midpoint. With a variable font, the browser renders every intermediate value, which is what makes the motion smooth.

Simple Transitions on Hover and Focus

The most common use is a hover state. Because font-weight maps directly to the wght axis, a plain transition works:

.nav-link {
  font-weight: 450;
  transition: font-weight 200ms ease-out;
}

.nav-link:hover,
.nav-link:focus-visible {
  font-weight: 700;
}

The width problem

There is a catch. In most fonts, heavier weights are wider. As the link gets bolder, it grows, pushing its neighbors sideways every frame. In a horizontal menu, the whole row wiggles.

You have three good ways to avoid that:

  1. Animate grade instead of weight. The GRAD axis, available in fonts such as Roboto Flex, changes stroke thickness while keeping every glyph the same width. Nothing moves.
.nav-link {
  font-variation-settings: "GRAD" 0;
  transition: font-variation-settings 200ms ease-out;
}

.nav-link:hover,
.nav-link:focus-visible {
  font-variation-settings: "GRAD" 150;
}
  1. Use a uniwidth or monospaced font. Some families are designed so that every weight has identical advance widths. Monospaced fonts such as Recursive Mono have this property by definition.

  2. Reserve the bold width. Render an invisible bold copy of the label to size the element, then animate the visible text inside that box:

.nav-link {
  display: inline-grid;
}

.nav-link::after {
  content: attr(data-label);
  font-weight: 700;
  visibility: hidden;
  height: 0;
  overflow: hidden;
  grid-area: 1 / 1;
}

.nav-link > span {
  grid-area: 1 / 1;
  font-weight: 450;
  transition: font-weight 200ms ease-out;
}

.nav-link:hover > span,
.nav-link:focus-visible > span {
  font-weight: 700;
}
<a class="nav-link" href="/pricing" data-label="Pricing"><span>Pricing</span></a>

The pseudo-element sizes the grid cell to the bold width, so the label can change weight without changing the link's size.

Keyframe Animations

For looping or entrance animations, use @keyframes. When you animate font-variation-settings, list the same axes in the same order in every keyframe so the browser can interpolate each one:

@keyframes breathe {
  0%,
  100% {
    font-variation-settings: "wght" 200, "wdth" 100;
  }
  50% {
    font-variation-settings: "wght" 800, "wdth" 125;
  }
}

.hero-title {
  font-family: "Roboto Flex", system-ui, sans-serif;
  animation: breathe 4s ease-in-out infinite;
}

Using font-variation-settings for weight here is acceptable because the animation fully controls the value; for static styling, keep weight on font-weight.

If you only animate registered axes, the high-level properties are cleaner:

@keyframes weight-in {
  from {
    font-weight: 100;
    font-stretch: 75%;
  }
  to {
    font-weight: 750;
    font-stretch: 100%;
  }
}

.headline {
  animation: weight-in 900ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
}

Animating Axes Independently with @property

font-variation-settings is one property holding all axes, so two animations that each target a different axis overwrite each other. The solution is to animate custom properties, one per axis, and build font-variation-settings from them.

A normal custom property cannot be interpolated because the browser does not know its type. Registering it with @property tells the browser it is a number, which makes it animatable:

@property --wght {
  syntax: "<number>";
  inherits: true;
  initial-value: 400;
}

@property --wdth {
  syntax: "<number>";
  inherits: true;
  initial-value: 100;
}

@property --grad {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}

.flex-text {
  font-variation-settings: "wght" var(--wght), "wdth" var(--wdth), "GRAD" var(--grad);
  transition: --grad 250ms;
}

@keyframes pulse-weight {
  50% {
    --wght: 800;
  }
}

@keyframes pulse-width {
  50% {
    --wdth: 130;
  }
}

.flex-text.is-playing {
  animation:
    pulse-weight 1.6s ease-in-out infinite,
    pulse-width 2.3s ease-in-out infinite;
}

.flex-text:hover {
  --grad: 120;
}

Two animations with different durations now run on different axes, and a hover transition adjusts a third, all without conflict. @property is supported in all current browsers.

Staggered Letter Animations

Wave effects animate each letter with a slight delay. Split the text into spans, give each one an index in a custom property, and offset the animation:

<h1 class="wave" aria-label="Variable">
  <span aria-hidden="true" style="--i: 0">V</span>
  <span aria-hidden="true" style="--i: 1">a</span>
  <span aria-hidden="true" style="--i: 2">r</span>
  <span aria-hidden="true" style="--i: 3">i</span>
  <span aria-hidden="true" style="--i: 4">a</span>
  <span aria-hidden="true" style="--i: 5">b</span>
  <span aria-hidden="true" style="--i: 6">l</span>
  <span aria-hidden="true" style="--i: 7">e</span>
</h1>
@keyframes wave {
  0%,
  100% {
    font-weight: 200;
  }
  50% {
    font-weight: 900;
  }
}

.wave span {
  display: inline-block;
  animation: wave 1.8s ease-in-out infinite;
  animation-delay: calc(var(--i) * 90ms);
}

The aria-label on the heading and aria-hidden on the spans matter. Without them, some screen readers announce each letter separately, which turns a heading into gibberish.

Generating the spans with a few lines of JavaScript avoids writing them by hand:

document.querySelectorAll("[data-split]").forEach((el) => {
  const text = el.textContent.trim();
  el.setAttribute("aria-label", text);
  el.textContent = "";
  [...text].forEach((char, i) => {
    const span = document.createElement("span");
    span.textContent = char;
    span.setAttribute("aria-hidden", "true");
    span.style.setProperty("--i", i);
    el.append(span);
  });
});

Scroll-Driven Font Animation

Scroll-driven animations let you tie an axis to scroll position without JavaScript. As a heading enters the viewport, it can grow from light to heavy:

@keyframes heavier {
  from {
    font-weight: 150;
    font-stretch: 80%;
  }
  to {
    font-weight: 800;
    font-stretch: 100%;
  }
}

@supports (animation-timeline: view()) {
  .section-title {
    animation: heavier linear both;
    animation-timeline: view();
    animation-range: entry 10% cover 40%;
  }
}

animation-timeline: view() drives the animation by the element's visibility in the viewport, and animation-range limits it to the stretch between entering and reaching 40% coverage. Scroll-driven animations are supported in Chromium-based browsers and Safari 26; the @supports block makes sure other browsers simply show the final static style.

Pointer-Driven Effects with JavaScript

For effects that respond to the cursor, update a custom property in a requestAnimationFrame callback and let CSS do the rest:

const title = document.querySelector(".reactive-title");
let frame = null;

title.addEventListener("pointermove", (event) => {
  if (frame) return;
  frame = requestAnimationFrame(() => {
    const rect = title.getBoundingClientRect();
    const x = (event.clientX - rect.left) / rect.width;
    title.style.setProperty("--wght", (200 + x * 700).toFixed(0));
    frame = null;
  });
});

title.addEventListener("pointerleave", () => {
  title.style.setProperty("--wght", 400);
});
.reactive-title {
  font-variation-settings: "wght" var(--wght);
  transition: --wght 150ms linear;
}

Throttling to one update per frame keeps the main thread free. Because --wght is registered with @property, the CSS transition smooths out the jumps between pointer events.

Performance: What Font Animation Really Costs

Font axis animation is not free. Unlike transform and opacity, which the compositor can animate on the GPU, changing a font axis requires the browser to:

  1. Recompute glyph outlines for the new axis values.
  2. Re-run text layout, because advance widths may have changed.
  3. Repaint the text.

That happens on the main thread, every frame. For a single heading or a handful of links, it is cheap on modern devices. For a full paragraph, a long list, or dozens of animated elements, it can drop frames on mid-range phones and hurt responsiveness while the animation runs.

Practical rules:

  • Animate short text only. Headlines, labels, buttons, logos.
  • Prefer width-neutral axes (GRAD, uniwidth designs) so layout around the text does not shift.
  • Isolate the element. contain: layout paint on the animated element limits how much of the page the browser has to recalculate.
  • Do not run infinite loops off-screen. Pause animations when elements leave the viewport, or use scroll-driven animations that only run while visible.
  • Test with CPU throttling. In the DevTools Performance panel, set 4× or 6× slowdown and record. Look for long frames and repeated Layout events.
.hero-title {
  contain: layout paint;
}

Respecting Reduced Motion

Some users experience nausea, dizziness, or distraction from motion. Operating systems expose a reduce motion setting, and CSS can read it. Treat motion as an enhancement that only runs when the user has not asked for less:

.hero-title,
.wave span,
.section-title {
  animation: none;
}

@media (prefers-reduced-motion: no-preference) {
  .hero-title {
    animation: breathe 4s ease-in-out infinite;
  }

  .wave span {
    animation: wave 1.8s ease-in-out infinite;
    animation-delay: calc(var(--i) * 90ms);
  }
}

Short hover transitions of 200 ms are generally acceptable even with reduced motion, but loops, entrance effects, and scroll-linked motion should be disabled. Two WCAG criteria apply directly:

  • 2.2.2 Pause, Stop, Hide (Level A): motion that starts automatically, lasts more than five seconds, and runs alongside other content must be pausable.
  • 2.3.3 Animation from Interactions (Level AAA): motion triggered by interaction should be possible to disable.

For broader guidance on how much motion is too much, see how to use kinetic typography without overwhelming users and how to make website typography accessible.

Animating Variable Fonts FAQ

Not smoothly. With static fonts the browser can only switch between the weight files you loaded, so the animation jumps at some point between keyframes. Smooth weight animation requires a variable font whose weight axis covers the range you animate.

The keyframes probably list different axes or list them in a different order. Include the same axis tags in every keyframe. If you need to animate axes independently, register one custom property per axis with @property and animate those instead.

Heavier weights are usually wider, so each link grows during the animation and pushes its neighbors. Animate a grade axis instead, use a uniwidth font, or reserve the bold width with an invisible copy of the label.

It costs more than animating transform or opacity because every frame requires text layout and paint on the main thread. On short text such as headings and buttons that cost is small. Avoid animating long passages or many elements at once, and test with CPU throttling.

Not yet in every browser. Chromium-based browsers and Safari 26 support scroll-driven animations. Wrap them in a supports check so other browsers show the final static style, which keeps the page fully usable.

Put an aria-label with the full text on the parent element and mark each letter span as aria-hidden. Screen readers then announce the word once instead of spelling it out letter by letter.

Conclusion

Variable fonts turn typography into something you can animate with ordinary CSS. Transitions on font-weight and font-stretch handle hover and focus states, keyframes handle entrances and loops, @property lets you animate each axis independently, and scroll-driven timelines connect font changes to scroll position without JavaScript.

The craft is in restraint. Animate short text, prefer axes like grade that do not change width, isolate animated elements with containment, test on throttled CPUs, and always respect prefers-reduced-motion. Done that way, animated type adds personality and feedback to an interface without costing smoothness or accessibility. For the fundamentals behind all of this, see what variable fonts are and why you should use them.

Here are some useful references for going deeper on animating variable fonts:

  1. MDN Web Docs: font-variation-settings — syntax and interpolation behavior for axis values.
  2. MDN Web Docs: @property — registering typed custom properties so they can be animated.
  3. MDN Web Docs: Scroll-driven animations — animation-timeline, view(), and animation-range.
  4. W3C: Understanding SC 2.2.2 Pause, Stop, Hide — when moving content must be pausable.
  5. web.dev: prefers-reduced-motion — how to honor the user's motion preferences in CSS and JavaScript.
Share :

Related Posts

Ascenders, Descenders, and Baselines: The Anatomy of a Letterform

Ascenders, Descenders, and Baselines: The Anatomy of a Letterform

You align an icon next to a button label and it looks a pixel or two too high, no matter how you adjust vertical-align. You set overflow: hidden

Continue Reading
Are Google Fonts GDPR-Compliant?

Are Google Fonts GDPR-Compliant?

In late 2022, thousands of small business owners in Germany and Austria opened letters demanding a few hundred euros in "damages" because their websi

Continue Reading
How to Audit Web Font Performance with Lighthouse?

How to Audit Web Font Performance with Lighthouse?

A client sends you a screenshot of their PageSpeed Insights report: performance score 61, LCP 3.9 seconds, and a vague list of warnings. They want to

Continue Reading