Animating Font Weight with Variable Fonts

This guide is part of the Variable Font Axis Tuning: wght, wdth & slnt topic, itself under the Typography Fundamentals & System Architecture area.

Variable fonts collapse an entire weight family — Thin through Black — into one file with a continuous wght axis, which makes it tempting to animate that axis directly: a button that thickens on hover, a heading that "breathes" from 400 to 700 on scroll, a loading indicator that pulses weight instead of opacity. The problem is that every intermediate value on the wght axis is a genuinely different glyph outline. Unlike animating transform or opacity, which the browser can composite on the GPU without touching layout or paint, animating font weight forces the browser to re-rasterize the affected text on every single frame. Done carelessly, a weight animation that looks fine on a fast desktop GPU will visibly stutter on a mid-range phone, and it will fight prefers-reduced-motion users who explicitly asked for less visual movement. This guide covers the two ways to drive wght, the performance cost of each, and how to keep the animation both smooth and accessible.

Prerequisites

You need a variable font that actually registers the wght axis — check with python -c "from fontTools.ttLib import TTFont; f=TTFont('font.woff2'); print(f['fvar'].axes)" or inspect the Fonts pane in Chrome DevTools, which lists every registered axis and its min/default/max. Confirm the font is already loaded before the animation starts (see the CSS Font Loading API implementation guide for a document.fonts.ready gate), because animating weight on a fallback font will produce a visible jump the moment the real variable font swaps in. It also helps to have already read the opsz axis guide, since some variable fonts couple wght and opsz and animating one without accounting for the other can produce a font that looks correct at rest but slightly odd mid-transition.

Implementation

The most direct approach is to transition the standard font-weight property. As of Chrome 98, Firefox 62, and Safari 16.4, browsers will interpolate font-weight across a transition or animation if the active font is variable and registers wght, without you needing font-variation-settings at all:

.weight-hover {
  font-weight: 400;
  transition: font-weight 220ms ease-out;
}

.weight-hover:hover,
.weight-hover:focus-visible {
  font-weight: 650;
}

This is the simplest option and should be your default: it degrades safely (a non-variable fallback just snaps between the two nearest static weights instead of interpolating), and it needs no extra CSS machinery. The 220ms duration matches typical UI micro-interaction timing — long enough to read as intentional, short enough not to feel sluggish under repeated hovers.

The limitation is that font-weight alone cannot animate custom axes like wdth or a font's own non-standard axes (many display variable fonts register grade or contrast axes beyond the registered four). For those, or when you want more explicit control over the interpolation curve, animate font-variation-settings directly. Because font-variation-settings is a string-valued property, the browser cannot naturally interpolate between two string values — "wght" 400 and "wght" 700 are just different strings to it. You have to register the custom property with @property so the browser knows it's a <number> and can tween it:

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

.headline {
  --wght: 400;
  font-variation-settings: "wght" var(--wght);
  transition: --wght 300ms ease-in-out;
}

.headline:hover {
  --wght: 750;
}

@property is supported in Chrome 85+, Firefox 128+, and Safari 16.4+; everywhere else the transition simply fails to animate and the weight jumps instantly, which is a safe degradation, not a broken one. This pattern is the only reliable way to animate multiple axes together — for example transitioning wght and a custom GRAD (grade) axis in lockstep for a richer hover effect — since you register one custom property per axis and reference them all inside a single font-variation-settings declaration.

A defensive, reduced-motion-aware variant

Weight animation is a visual flourish, not a functional requirement, which makes it exactly the kind of effect prefers-reduced-motion: reduce should disable. It is also worth capping the animation to the loaded, buffered font — if the variable font is still downloading, you don't want the fallback font attempting (and failing) to interpolate a nonexistent axis. Combine both concerns in one block:

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

.headline {
  --wght: 400;
  font-variation-settings: "wght" var(--wght);
}

.fonts-loaded .headline {
  transition: --wght 300ms ease-in-out;
}

.fonts-loaded .headline:hover {
  --wght: 750;
}

@media (prefers-reduced-motion: reduce) {
  .fonts-loaded .headline {
    transition: none;
  }

  .fonts-loaded .headline:hover {
    --wght: 750; /* still change weight, just instantly */
  }
}

The .fonts-loaded class here assumes you toggle it once document.fonts.ready resolves, the same pattern used across the font loading strategies area — this prevents the transition rule from ever applying to a fallback font mid-load. Under reduced motion, the weight change still happens (so the interaction remains legible — a hovered button still visibly thickens) but instantly rather than over 300ms, which satisfies the intent of the media query: remove animation, not remove all visual feedback.

If you need the same weight change driven by JavaScript rather than :hover — say, in response to a scroll position or an intersection observer — set the custom property from script and let the CSS transition handle the interpolation, rather than writing a requestAnimationFrame loop that recomputes font-variation-settings yourself:

const headline = document.querySelector(".headline");
const observer = new IntersectionObserver(
  ([entry]) => {
    headline.style.setProperty("--wght", entry.isIntersecting ? "750" : "400");
  },
  { threshold: 0.5 },
);
observer.observe(headline);

This keeps a single, declarative transition definition in CSS and avoids re-implementing easing curves in JavaScript, which is both more error-prone and harder for the browser to optimize.

Frame cost: font-weight vs transform Bar chart comparing per-frame main-thread cost in milliseconds for three approaches to animating font weight. Frame cost: font-weight vs transform font-weight transition 14ms rep… font-variation-settings 13ms rep… transform-only swap 2ms comp… ms per frame (16.6ms budget)
Animating font-weight repaints text every frame; a transform-based swap does not.

Why this is expensive, and how to budget for it

Both font-weight transitions and font-variation-settings transitions force a text-layer repaint on every frame the value changes, because the glyph outlines at wght: 512 are geometrically different from the outlines at wght: 513 — there's no way to fake that with GPU compositing the way there is for a translate or a fade. On a 16.6ms frame budget (60fps), repainting a headline-sized block of text typically costs 8–14ms depending on text length and font complexity, which leaves little headroom for anything else happening that frame. Contrast that with a transform: scale() or opacity transition, which the compositor can run at effectively zero main-thread cost once the layer is promoted.

Practical budgeting rules:

  • Scope the animation to small text blocks. Animating weight on a one-line button label is cheap; animating it across a full paragraph or a whole page's headings simultaneously multiplies the repaint cost linearly with glyph count.
  • Avoid animating weight on scroll-linked or continuously-running animations. A :hover transition runs once per interaction and settles; a scroll-driven or @keyframes-looped weight animation repaints every frame for as long as it runs, which is a much larger cumulative cost and a common source of jank reports in Lighthouse's Long Tasks audit.
  • Never animate weight underneath frequently re-rendering UI, such as inside a virtualized list row — the repaint cost compounds with whatever layout thrashing the list itself is already doing.
  • Test on a throttled CPU. Chrome DevTools' Performance panel with 4x or 6x CPU throttling will surface weight-animation jank that is invisible on a development machine; this is the same throttling profile used when auditing Core Web Vitals more generally.
wght animation support Comparison table of browser support for animating font-weight and font-variation-settings on variable fonts. wght animation support Chrome Firefox Safari font-weight transition 98+ 62+ 16.4+ font-variation-settin… 62+ 62+ 11+ @property number 85+ 128+ 16.4+
Direct font-weight transitions on variable fonts across current engines.

Verification

Open Chrome DevTools, go to the Rendering tab, and enable Paint flashing. Hover the element with the weight transition — you should see the text region flash green on every frame of the transition, confirming it is genuinely repainting (this is expected and not itself a bug; the concern is whether the rate of repaint stays under budget). Then switch to the Performance panel, record a trace while triggering the hover several times, and inspect the Main track: each transition frame should show a Paint and often a Layout (if the transitioning element's box also changes, e.g. because weight change affects measured text width) entry costing well under 16ms combined, with no red "long task" markers. If frames are dropping, reduce the transition duration, shrink the animated text region, or fall back to the plain font-weight transition (skipping the custom-property axis machinery) since it is marginally cheaper in most engines' current implementations. Also verify the reduced-motion branch by toggling Emulate CSS media feature prefers-reduced-motion in the Rendering tab and confirming the weight still changes but without a visible transition.

Building a reduced-motion-safe weight animation Numbered steps for implementing an accessible variable font weight animation. Building a reduced-motion-safe weight animation 1 Register axis @property --wght 2 Transition wght font-variation-settings 3 Gate with media query prefers-reduced-motion 4 Verify on compositor DevTools rendering tab
Four steps from static wght value to a compositor-friendly, accessible transition.

Common Pitfalls

  • Animating weight before the variable font has loaded. The fallback system font either ignores the axis entirely (font-weight then jumps in discrete static steps if the fallback happens to have multiple weight files installed) or animates nothing at all, producing an inconsistent first-visit experience. Gate the transition behind a fonts-loaded class set from document.fonts.ready.
  • Skipping @property and expecting font-variation-settings to tween. Without registering the custom property's syntax as <number>, the browser treats the whole string as an opaque value and the transition silently no-ops — the weight snaps instead of easing, with no console warning to explain why.
  • Ignoring prefers-reduced-motion. A pulsing or looping weight animation is exactly the kind of motion the media query exists to suppress; leaving it unguarded is both an accessibility gap and a Lighthouse "Respects user's prefers-reduced-motion" finding in manual audits.
  • Animating weight across large blocks of body text. The repaint cost scales with the amount of text being re-rasterized; a hover effect on a button is cheap, the same effect applied to an entire animated paragraph is not, and will show up as dropped frames on mid-range mobile hardware.
  • Forgetting the accessible weight floor. An animation that dips down toward the thinnest end of the wght range during its transition can momentarily produce low-contrast, hard-to-read text; see the companion guide on clamping variable font weight for accessibility for setting a safe minimum before you wire up any animation.

A note on multi-axis choreography

Some display variable fonts expose more than one axis worth animating together — wght alongside a custom GRAD (grade, a subtle weight-like axis meant for dark-mode contrast compensation) or alongside wdth. When you animate more than one axis in the same transition, register a separate @property custom property per axis rather than trying to interpolate a single combined string. Each axis should have its own syntax: "<number>" declaration and its own initial-value, and all of them are referenced together inside one font-variation-settings value:

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

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

.display-heading {
  --wght: 400;
  --wdth: 100;
  font-variation-settings: "wght" var(--wght), "wdth" var(--wdth);
  transition: --wght 280ms ease-in-out, --wdth 280ms ease-in-out;
}

.display-heading:hover {
  --wght: 700;
  --wdth: 92;
}

Keeping the two axes on matched durations and easing curves avoids a visually distracting effect where the weight finishes changing well before the width does, which reads as unintentional rather than as a single cohesive motion. If the two axes must move at different rates for design reasons, that is a valid choice, but make it deliberately rather than by leaving one transition's duration at a browser default.

Frequently Asked Questions

Can I animate font-weight with a normal, non-variable font family? No — with a static font family, font-weight can only jump between whichever discrete weights actually have font files installed (e.g. 400 and 700), it cannot interpolate. Browsers that support animating font-weight continuously do so only when the resolved font is a variable font that registers the wght axis; on a static fallback the transition is either skipped or snaps at the halfway point of the transition duration.

Is font-variation-settings or font-weight the better property to animate? Prefer plain font-weight when you're only animating the standard weight axis — it requires no @property boilerplate and degrades more predictably on unsupported engines. Use font-variation-settings with registered @property custom properties only when you need to animate a non-standard axis or synchronize multiple axes (like wght and a custom grade axis) in one transition.

Does animating weight affect Cumulative Layout Shift? It can, if the heavier weight measures wider than the lighter one and the container isn't fixed-width, since the surrounding layout may reflow as the text box grows. Set the element's width or use font-variant-numeric: tabular-nums-style fixed-metric techniques where precise, and check the layout-shift entries from a PerformanceObserver during the transition the same way you would for any font swap, as described in the layout-shift debugging guides.

Related