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.
| concept | time-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.)
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.
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)
| intent | pattern | why |
|---|---|---|
| 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-[…] | 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
| browser | status | notes |
|---|---|---|
| Chrome / Edge 115+ | ✅ shipping | baseline since Aug 2023 |
| Safari | 🔜 26 (in development) | pre-26 shows the start state — provide a fallback |
| Firefox | 🚩 behind flag | layout.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: —