The first time I tried this, it didn’t work. The demo was inside a wrapper with overflow: hidden, and the scroll animation just sat there, frozen at its start value. Debugging that one failure taught me more about how scroll-driven animations actually resolve than any spec reading had.

font-variation-settings is an animatable CSS property. That means @keyframes can target it, and animation-timeline can drive it from scroll position. No JavaScript, no font loading tricks, no canvas. The technique is simple. Getting it to work inside a real layout is where the interesting parts live.

Why font-variation-settings is animatable

For a CSS property to be animatable, the browser needs to know how to calculate intermediate values between two states. Some properties snap (display, visibility). Others interpolate continuously. font-variation-settings is in the second group: the CSS Fonts Level 4 spec defines exactly how to interpolate between axis values. Two entries for the same axis — 'wght' 300 and 'wght' 700 — produce every value between them. It doesn’t jump. It steps through all of them.

The same is true for letter-spacing, color, opacity: any property the browser knows how to interpolate works inside a @keyframes block. You can animate all of them at once:

@keyframes weight {
  from {
    font-variation-settings: 'wght' 300;
    letter-spacing: 0.06em;
    color: rgba(255, 255, 255, 0.3);
  }
  to {
    font-variation-settings: 'wght' 700;
    letter-spacing: -0.03em;
    color: rgba(255, 255, 255, 0.95);
  }
}

Heavier type naturally needs tighter tracking. The two axes reinforce each other. Animating them together in a single keyframe means they always stay in sync.

The scroll container problem

The obvious first attempt is scroll() or view() as the animation timeline. It works in isolation. The moment you put it inside a layout component (a card, an article wrapper, a demo container) it breaks.

The reason: overflow: hidden creates a scroll container. This is a CSS spec detail that most people never think about because overflow: hidden usually means “clip this content.” But in the scrolling model, it also means “you are now a scroll container.” And scroll(nearest) and view() resolve to the nearest scroll container ancestor: your layout component, not the page.

The result is that the animation plays against a container that never scrolls, so it never progresses.

/* This creates a scroll container — breaks timeline resolution */
.demo-wrapper {
  overflow: hidden;
}

/* This clips visually without creating a scroll container */
.demo-wrapper {
  overflow: clip;
}

overflow: clip is the fix. It behaves identically to overflow: hidden visually: content outside the box is clipped. But it doesn’t create a scroll container, so scroll() and view() skip past it and resolve to the page. It also doesn’t break position: sticky, which overflow: hidden does for the same reason.

This is the change that makes everything else possible.

The sticky wrapper pattern

With the scroll container problem solved, the architecture is straightforward:

.outer {
  height: 300vh;               /* creates scroll space */
  view-timeline-name: --section;
  view-timeline-axis: block;
}

.sticky {
  position: sticky;
  top: 0;
  max-height: 100vh;           /* pins to viewport */
}

.heading {
  animation: weight linear both;
  animation-timeline: --section;
  animation-range: contain 0% contain 100%;
}

The outer wrapper is tall: 300vh means three full viewport heights of scroll. It carries the view-timeline-name, which creates a named timeline tied to its position in the page. The sticky inner element pins to the top of the viewport while the outer wrapper is in view.

The named timeline is what makes this composable. Instead of scroll(nearest) resolving to whatever scroll container happens to be nearby, --section is explicitly attached to the outer wrapper. The animation knows exactly where its timeline is coming from.

page scroll → font-variation-settings
wght

Weight.

Follows.

Scroll.

scroll ↓

.outer {
  height: 300vh;
  view-timeline-name: --section;
  view-timeline-axis: block;
}

.sticky {
  position: sticky;
  top: 0;
}

.heading {
  font-variation-settings: 'wght' 300;
  animation: weight linear both;
  animation-timeline: --section;
  animation-range: contain 0% contain 100%;
}

@keyframes weight {
  from {
    font-variation-settings: 'wght' 300;
    letter-spacing: 0.06em;
  }
  to {
    font-variation-settings: 'wght' 700;
    letter-spacing: -0.03em;
  }
}

animation-range: contain

contain 0% contain 100% is worth understanding precisely. The contain keyword refers to the range where the element fully covers the viewport: both edges of the viewport are inside the element’s bounds. For a 300vh wrapper, that’s the entire scroll distance between the element’s top edge reaching the viewport top and the element’s bottom edge leaving the viewport bottom.

The four range keywords:

KeywordWhen it fires
entryelement entering the viewport
containelement fully covering the viewport
exitelement leaving the viewport
coverentire journey from first pixel entering to last pixel leaving

contain is the right choice here because it locks the animation to exactly the duration when the sticky element is pinned. The animation starts when pinning begins and ends when it releases. They’re the same moment.

The weight lens

A different use of the same pattern: instead of one animation running the full range, eight animations each run a fraction of it, staggered so each word peaks at bold as the scroll position passes through it.

@keyframes lens {
  0%, 100% {
    font-variation-settings: 'wght' 300;
    letter-spacing: 0.03em;
    color: rgba(255, 255, 255, 0.15);
  }
  50% {
    font-variation-settings: 'wght' 700;
    letter-spacing: -0.02em;
    color: rgba(255, 255, 255, 0.95);
  }
}

/* Each item gets a slice of the full range */
.item:nth-child(1) { animation-range: contain  0% contain 16%; }
.item:nth-child(2) { animation-range: contain 13% contain 29%; }
.item:nth-child(3) { animation-range: contain 26% contain 42%; }
.item:nth-child(4) { animation-range: contain 39% contain 55%; }
.item:nth-child(5) { animation-range: contain 52% contain 68%; }
.item:nth-child(6) { animation-range: contain 65% contain 81%; }
.item:nth-child(7) { animation-range: contain 78% contain 94%; }
.item:nth-child(8) { animation-range: contain 84% contain 100%; }

Each slice is 16% wide, and adjacent slices overlap by 3%. That overlap means two words are partially animated at the same time during the transition: the outgoing word is fading down while the incoming word is rising up. Without it, there’s a gap where no word is highlighted, which reads as a stutter.

The keyframe runs 0% → 50% → 100%, which means each word peaks at the midpoint of its range slice. At the moment the scroll position hits 8% (midpoint of the first item’s 0–16% range), item 1 is fully bold. By the time it reaches 13%, item 2 has already started rising.

view-timeline weight lens — page scroll
variable
font
weight
axis
scroll
driven
timeline
motion
.outer {
  height: 500vh;
  view-timeline-name: --section;
}

.item {
  animation: lens linear both;
  animation-timeline: --section;
}

.item:nth-child(1) { animation-range: contain  0% contain 16%; }
.item:nth-child(2) { animation-range: contain 13% contain 29%; }
/* … */

@keyframes lens {
  0%, 100% { font-variation-settings: 'wght' 300; }
  50%       { font-variation-settings: 'wght' 700; }
}

Both demos are also available as standalone experiments in the lab.

What variable font axes are available

The wght axis is the most widely supported, but it’s not the only one. What’s available depends on the font:

AxisTagTypical rangeWhat it controls
Weightwght100–900stroke thickness
Widthwdth75–125condensed to expanded
Optical sizeopsz8–144detail for small vs large sizes
Slantslnt-15–0oblique angle
Italicsital0–1roman to italic

All of them animate the same way. A font that supports opsz can animate optical size from scroll: sharp detail at large sizes, simplified letterforms at small. Any registered axis can go into @keyframes.

Browser support

font-variation-settingsanimation-timelineview-timeline-name
Chrome✓ 62+✓ 115+✓ 115+
Firefox✓ 62+✓ 110+✓ 110+
Safari✓ 11+✓ 18+✓ 18+

Variable font support is broad. Scroll-driven animations landed in Safari 18 (September 2024), which is the constraint. For older Safari, the text renders at the from keyframe value (light weight, open tracking), which is a reasonable fallback.

The technique works because the browser already knows how to interpolate font variation axes. The scroll timeline is just a way of saying: instead of time driving the playhead, let scroll position drive it. Everything else follows from that.