Scroll-driven animations: it's time to retire your scroll listeners
For a decade, scroll effects meant the same ritual: attach a listener, throttle it with requestAnimationFrame, read scrollY, mutate styles, and pray the main thread stays under 16ms. Parallax libraries, reveal-on-scroll plugins, progress bars—all of it built on JavaScript polling a value the browser already knows.
That era is over. Scroll-driven animations let you bind any CSS animation directly to scroll position, and they run off the main thread, on the compositor. No listeners. No jank. No library. Let's look at what that actually changes.
The old way is a performance tax
Every scroll listener is a contract with the main thread. Even a well-throttled handler forces style recalculation in JavaScript, and one slow frame is all it takes for a parallax effect to feel like it's stuttering underwater. The browser, meanwhile, is tracking scroll position natively at display refresh rate—we were just never allowed to use it declaratively.
/* ❌ The old ritual: JS polling the scroll position */
window.addEventListener('scroll', () => {
bar.style.width = `${(scrollY / max) * 100}%`;
}, { passive: true });
/* ✅ The new contract: pure CSS, compositor thread */
.progress-bar {
animation: grow auto linear;
animation-timeline: scroll(root);
}
"Scroll position isn't an event to be listened for. It's a timeline to be animated along."
Two timelines, two mental models
The spec gives you two primitives. scroll() maps an animation's progress to how far a container has scrolled—perfect for reading-progress bars, shrinking headers, and parallax layers. view() maps progress to an element's journey through the viewport—which quietly replaces almost every use of IntersectionObserver for reveal animations.
Here's the classic fade-up reveal, with zero JavaScript. The animation-range line is doing the subtle work: the animation starts when the element enters the viewport and finishes when it's 40% of the way in, so content is fully legible before the reader reaches it.
@keyframes fade-up {
from { opacity: 0; transform: translateY(32px); }
to { opacity: 1; transform: translateY(0); }
}
.card-reveal {
animation: fade-up auto ease-out both;
animation-timeline: view();
/* Finish while the reader can still see it happen */
animation-range: entry 0% entry 40%;
}
Scrubbing, not triggering
The mental shift that matters: these animations are scrubbed, not triggered. Scroll down halfway, the animation is halfway done. Scroll back up, it reverses. The user's thumb is the playhead. That reversibility is what makes scroll-driven effects feel physical rather than scripted—and it's precisely the behavior that was painful to build by hand.
Ship it as an enhancement
Browser support is broad now, but the right posture is still progressive enhancement. Author the static layout first, then layer the timeline on inside a feature query. Users on older engines get a perfectly readable page; everyone else gets the motion. And respect prefers-reduced-motion—scroll-scrubbed movement is exactly the kind of animation vestibular-sensitive users need to opt out of.
@supports (animation-timeline: scroll()) {
@media (prefers-reduced-motion: no-preference) {
.card-reveal {
animation: fade-up auto ease-out both;
animation-timeline: view();
}
}
}
Delete the scroll listener. Delete the throttle helper. Delete the 40kb animation library you imported for one reveal effect. The platform caught up—and the version it shipped is faster, reversible by default, and three lines long.