w3tweaks.com · CSS Tutorial

CSS Scroll-Driven Animations

animation-timeline · scroll() · view() — zero JavaScript, off the main thread

Tab 1

Scroll Progress Timelines — Scroll the Box Below

The progress bar and the spinning badge are both driven by the container's scroll position — zero JavaScript, no scroll listeners, running entirely on the compositor thread. Scroll the box and scrub them back and forth.

⚠️ Your browser doesn't support animation-timeline yet (Firefox stable keeps it behind a flag). The demos below will appear static — everything still works, which is exactly the progressive-enhancement point this tutorial teaches. Try Chrome, Edge, or Safari to see them move.
Scrollable container ↓bar + badge scrub with scroll
🌀
Scroll me — watch the bar fill and the badge spin…
The animation progress = scroll progress.
Scroll back up — everything scrubs in reverse.
No scroll event listeners were harmed.
This runs off the main thread entirely.
Busy JavaScript can't make this jank.
Almost at the end…
100%! Now scrub backwards. 🎉
/* 1. Name a timeline on the scroll container */
.scroller { scroll-timeline: --my-timeline; }

/* 2. Attach any keyframe animation to it */
.progress-bar {
  transform-origin: left;
  animation: growBar linear;
  animation-timeline: --my-timeline; ← AFTER the shorthand!
}
@keyframes growBar { from{transform:scaleX(0)} to{transform:scaleX(1)} }

/* Anonymous version — nearest scroller, no name needed */
.el { animation-timeline: scroll(); }
/* scroll(root) = page scroll · scroll(self inline) = own x-axis */
The mental shift: @keyframes percentages never meant "time" — they mean "progress." animation-timeline just swaps the progress source: instead of the clock, it's the scroll position. Duration becomes meaningless (it's ignored), and scrolling backwards scrubs the animation backwards. Easing, fill modes, and delays all still work.
Tab 2

View Progress Timelines — Reveal on Scroll, No IntersectionObserver

Each card animates based on its own visibility in the scrollport. Scroll the box: cards fade up during their entry range. The last card uses cover — it color-shifts across its entire visible journey.

Scrollable container ↓cards reveal via view()
↓ scroll down — cards animate as they enter ↓

Card one — entry range

I fade and slide up only while entering the scrollport. Once fully in, I'm done. Scroll me back out and in — I replay in both directions.

Card two — same rule

One CSS rule handles every card. With IntersectionObserver, this needed observer setup, threshold tuning, and class juggling per element.

Card three — still one rule

animation-range: entry 0% entry 100% — the animation maps exactly onto the entry phase.

Card four — cover range

I use animation-range: cover — my border and background shift color across my ENTIRE visible journey, from first pixel in to last pixel out. Scroll slowly and watch.

— end — scroll back up to replay ↑
.card {
  animation: revealUp linear both; ← "both" prevents snap-back
  animation-timeline: view();
  animation-range: entry 0% entry 100%;
}
The Four Named Ranges — element traveling through the scrollport
← scrollport →
below
entry
contain
exit
gone
entry — crossing the start edge contain — fully visible exit — crossing the end edge cover — entire visible journey
Stagger without JavaScript: a negative animation-delay shifts where each element's animation starts within its timeline — give the 2nd, 3rd, 4th card increasing negative delays and they cascade. Alternatively, offset ranges per child: animation-range: entry 10% entry 110%.
Tab 3

The Gotchas That Generate Every Bug Report

Five sharp edges the happy-path tutorials skip — plus the production-safe pattern.

⚠️ Gotcha 1: The Shorthand Reset
/* ❌ shorthand AFTER timeline — resets it to auto! */
.el {
  animation-timeline: view();
  animation: reveal linear; ← killed it
}

/* ✅ shorthand FIRST, timeline after */
.el {
  animation: reveal linear;
  animation-timeline: view();
}

animation-timeline is a reset-only member of the animation shorthand: the shorthand always resets it to auto and can never set it. The #1 "works in isolation, breaks in my codebase" cause.

⚠️ Gotcha 2: overflow-x:hidden Kills view()
/* ❌ The classic scrollbar-hiding trick... */
html, body { overflow-x: hidden; }
/* ...turns body into the scroll container,
   breaking view() timeline calculations */

/* ✅ Clip without creating a scroller */
html, body { overflow-x: clip; }

overflow: clip cuts off overflow WITHOUT establishing a scroll container — the modern replacement for the hack, and it keeps view() working against the real viewport.

⚠️ Gotcha 3: The Invisible-Forever Bug
/* ❌ Hidden by default = invisible in Firefox */
.card { opacity: 0; }
.card { animation: fadeIn; animation-timeline: view(); }

/* ✅ GOLDEN RULE: default = finished state */
.card { opacity: 1; }
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .card { animation: fadeIn linear both; animation-timeline: view(); }
  }
}

The classic shipped bug: cards stuck at opacity: 0 forever in unsupporting browsers. Author the finished state as default, layer the animation inside @supports + reduced-motion guards. Worst case: graceful, never broken.

🚀 Only transform & opacity Are Free
/* ✅ compositor-thread cheap */
transform, opacity

/* ⚠️ main-thread expensive per scroll frame */
width, height, top, left, margin,
box-shadow, border-radius, filter*

The off-main-thread superpower only applies to compositor properties. Animate layout properties on a scroll timeline and you've rebuilt scroll jank in pure CSS. Stick to transform and opacity; fake size changes with scale().

♿ prefers-reduced-motion Is Not Optional
@media (prefers-reduced-motion: no-preference) {
  /* parallax & motion here ONLY */
}

/* Reduced motion ≠ no animation:
   a color-only scroll animation can stay */

Parallax and multi-speed movement are documented vestibular-disorder triggers. Gate motion behind no-preference. Nuance: reduced motion isn't "no animation" — opacity fades and color shifts are generally fine to keep.

🔮 What's Next: animation-trigger
/* Scroll-TRIGGERED (not scrubbed):
   plays ONCE on its own clock when visible */
.card {
  animation: pop 0.5s ease-out;
  animation-trigger: view() play-forwards;
}

Scrubbing feels wrong for "play this entrance once" — that's what animation-trigger (Chrome 145+) delivers: a real timed animation, fired by visibility. The final IntersectionObserver use case, falling. Treat as enhancement-only for now.

Browser support (mid-2026): Chrome/Edge 115+, Safari shipped, Firefox stable still behind the layout.css.scroll-driven-animations.enabled flag (on in Nightly; a named Interop 2026 priority). ~83% global — close to Baseline but not quite. This is fine: scroll-driven animations are progressive enhancement by design. Unsupported browsers ignore animation-timeline and show your (correct) default state. Ship it today with the golden-rule pattern.