scroll-driven animations

[check: …]
📖 Pair this live demo with the companion guide (.md) — this page is the rendered ground truth. ↗ Builds on keyframes & the --animate-* namespace: scroll-driven animations reuse the exact same @keyframes — only the timeline binding (animation-timeline) changes from time to scroll position.

time-driven → scroll-driven: the retargeting

A normal CSS animation plays over time: 0% at 0 ms → 100% at duration. Set animation-timeline and that same @keyframes now plays over scroll position: 0% at scroll-top → 100% at scroll-bottom. No scroll listener, no requestAnimationFrame loop, no JS — the compositor drives it, so it stays smooth on the busiest pages.

concepttime-driven (default)scroll-driven
timeline source real time (a clock) scroll() offset or view() exposure
what advances 0%→100% elapsed ms / animation-duration scroll position (or element entering the scrollport)
duration still required? yes — sets the length ignored — the timeline controls pacing; use linear timing
direction of play forward only (unless alternate) scrubs both ways — scroll up and the animation reverses
Tailwind v4 utility animate-[wiggle_1s_linear] [animation-timeline:scroll()] + [animation-name:foo] (longhands)
main thread / compositor usually main-thread (unless transform/opacity) runs off the compositor — jank-free even under JS load

1 · scroll() — the page progress bar at the top ↑

The thin cyan bar pinned to the top of this window is position:fixed and bound to animation-timeline:scroll(). Scroll this page up and down — the bar's scaleX scrubs in lock-step with your scroll position, and it reverses when you scroll up. Because the bar is fixed, its nearest scrollable ancestor is the document root, so it tracks the whole page. (If you're on Safari pre-26 or Firefox without the flag, the bar shows at full width — see the gold-check badge.)

/* The two halves: keyframes (what to draw) + timeline (when to draw it). */ @keyframes grow-progress { from { transform: scaleX(0); } to { transform: scaleX(1); } } /* markup: longhands only — the `animation` SHORTHAND would reset animation-timeline */ <div class="fixed top-0 left-0 right-0 h-1.5 bg-cyan-400 [transform-origin:left_center] [animation-name:grow-progress] [animation-timing-function:linear] [animation-timeline:scroll()]"></div>

scroll() arguments (all optional): scroll(<scroller> <axis>). Scroller ∈ nearest (default) · root; axis ∈ block (default, vertical) · inline · x · y. So scroll(root inline) tracks horizontal page scroll.

2 · view() — reveal cards as they enter the scrollport ↓

view() ties progress to the element's own journey across the scrollport (not the container's overall scroll). Each card below is bound to [animation-timeline:view()] with [animation-range:entry] — it starts hidden (opacity:0, shifted down) and animates to its resting state during the entry portion only, so it's fully revealed the moment it's on screen. Scroll the box ↓ and watch each card pop in independently.

↓ scroll inside this box ↓

card 1 · view()

Bound to animation-timeline:view(). Reveals during animation-range:entry.

card 2

Each card owns its own timeline — they stagger naturally by scroll position.

card 3

Scroll back up — the animation reverses. No JS state machine needed.

card 4

fill-mode:both holds the start state before entry (so cards above the fold aren't flashing).

card 5

End of the scroll — you've scrubbed through five independent view timelines.

view() arguments: view(<inset> <axis>) — e.g. view(20% block) shrinks the "exposed" band by 20% on each side. Pair with animation-range to pick WHICH leg of the journey animates: entry (entering), exit (leaving), cover (the whole crossing, the default), or contain (fully inside).

3 · parallax — background drifts slower than foreground

Same scroll() timeline, different payload: a sticky background layer translates -200px over the full container scroll while the foreground cards scroll 1:1 — the speed mismatch reads as depth. The background is position:sticky so it stays pinned; the scroll-driven translateY adds the slow drift on top.

parallax hero

foreground · scrolls 1:1

The bg behind this card drifts slower than the card itself — that's the parallax.

card 2

Same scroll() as the progress bar — the nearest scroller is this box, not the page.

card 3

Because it's compositor-driven, the bg stays smooth even while JS is busy.

card 4

Scroll back up — the drift reverses, just like the progress bar.

intent → pattern (Tailwind v4 arbitrary properties)

intentpatternwhy
page scroll progress bar fixed ... [animation-name:grow] [animation-timing-function:linear] [animation-timeline:scroll()] scroll() on a fixed element resolves to the root scroller
reveal on scroll into view [animation-name:reveal] [animation-range:entry] [animation-fill-mode:both] [animation-timeline:view()] view() binds to the element's own crossing; entry finishes reveal as it lands
parallax inside a box sticky ... [animation-name:drift] [animation-timeline:scroll()] on the bg layer sticky pins the layer; scroll() (nearest = the box) drifts it slower than foreground
track horizontal scroll [animation-timeline:scroll(inline)] second arg picks the axis
animate only the exit leg [animation-range:exit] view() range keywords: entry · exit · cover · contain
❌ DON'T mix with animate-[…] animate-[grow_linear] [animation-timeline:scroll()] the animation shorthand resets animation-timeline to auto — use longhands
name a timeline for siblings parent: [timeline-scope:--foo] · source: [animation-timeline:--foo] lets an element A's scroll drive element B elsewhere in the tree

browser support & the gold-check

browserstatusnotes
Chrome / Edge 115+✅ shippingbaseline since Aug 2023
Safari🔜 26 (in development)pre-26 shows the start state — provide a fallback
Firefox🚩 behind flaglayout.css.scroll-driven-animations.enabled in about:config

Progressive enhancement: set the element's resting state in plain CSS (e.g. the progress bar is bg-cyan-400 at full width by default). On a browser without animation-timeline, the @keyframes simply never bind and the element shows its natural state — no broken layout. The gold-check badge detects support via CSS.supports('animation-timeline','scroll()') and reports N/A (amber) instead of FAIL, so the page never lies about coverage.

animation-timeline: — · supports: —