Scroll-Driven Animations: The Complete Playbook for 2026
A decade of scroll effects ran on the main thread via scroll listeners and requestAnimationFrame. This playbook covers parallax, progress bars, and reveal effects that run off it instead.
Paul Radford — Full-Stack Developer & Editor. Paul is a full-stack developer and editor of template.tips. Articles here are AI-drafted and reviewed by Paul, who knows the code well enough to catch what's wrong and cut the hype.

Scroll-driven animations link an element's visual state to scroll position using CSS animation-timeline, scroll(), and view(), or the JavaScript ScrollTimeline and ViewTimeline APIs. As of early 2026 they run natively in Chromium and Firefox, letting you build parallax, progress bars, and reveal effects without scroll event listeners or layout thrashing.
What problem does this actually solve?
For a decade, scroll effects meant attaching a scroll listener, reading window.scrollY, and mutating styles inside requestAnimationFrame. That approach works, but it runs on the main thread, competes with layout and paint, and tends to jank on lower-end devices the moment you add more than two or three animated elements.
Scroll-driven animations move the timeline calculation into the browser's animation engine, off the main thread in Chromium's implementation. Instead of asking "where is the user right now, and what style should I set," you declare "this animation's progress equals scroll progress" and let the browser interpolate. That's a fundamentally different execution model, and it's why the performance ceiling is so much higher.
The tradeoff: you lose imperative control. You can't easily inject conditional logic mid-scroll ("if user scrolled past section 3 AND clicked a button, do X") without falling back to JavaScript observers. For anything beyond straightforward progress-linked visuals, expect to combine both approaches.
The two timeline types, and when to reach for each
CSS gives you two timeline sources:
scroll() ties animation progress to the scroll position of a scroller itself. Use it for things like a reading progress bar at the top of an article, or a background hue that shifts as the whole page scrolls.
view() ties animation progress to how far an element has traveled through its scroller's visible area. Use it for reveal-on-scroll effects: fade in a card as it enters the viewport, fade it out as it leaves.
1/* Reading progress bar tied to document scroll */2.progress-bar {3 position: fixed;4 top: 0;5 left: 0;6 height: 4px;7 background: hotpink;8 transform-origin: left;9 transform: scaleX(0);1011 /* Link this animation to the nearest scrollable ancestor */12 animation: grow-progress linear;13 animation-timeline: scroll(root);14}1516@keyframes grow-progress {17 from { transform: scaleX(0); }18 to { transform: scaleX(1); }19}1/* Fade and lift each card as it enters the viewport */2.card {3 opacity: 0;4 transform: translateY(40px);5 animation: reveal linear both;6 animation-timeline: view();7 /* Start the animation when the element is 10% into view,8 finish when it's 40% through, so it settles early */9 animation-range: entry 10% cover 40%;10}1112@keyframes reveal {13 from { opacity: 0; transform: translateY(40px); }14 to { opacity: 1; transform: translateY(0); }15}The animation-range property is where most of the design nuance lives. entry, contain, exit, cover, and named ranges let you scope exactly which portion of the element's transit through the viewport maps to the 0-100% animation progress. Get this wrong and your reveal either finishes before the element is fully visible or never completes at all.
How do you handle browsers that don't support it yet?
This is the part that separates a portfolio demo from production code. As of 2026, Chrome and Edge have supported animation-timeline since Chrome 115 (July 2023), and Firefox shipped support in Firefox 133 (late 2024). Safari, however, has not shipped scroll-driven animations in a stable release as of this writing, tracked in WebKit's open feature status. Check current numbers on caniuse before you ship, because this is exactly the kind of feature where "I checked six months ago" is not good enough.
The practical fix is @supports, paired with a JavaScript fallback for the browsers that need it:
1/* Default state works everywhere: element is visible, no animation */2.card {3 opacity: 1;4}56@supports (animation-timeline: view()) {7 .card {8 opacity: 0;9 animation: reveal linear both;10 animation-timeline: view();11 animation-range: entry 10% cover 40%;12 }13}For Safari and older browsers, you have two honest choices. Either ship the static, fully-visible fallback (my default recommendation for content-heavy sites where the animation is decorative), or polyfill with IntersectionObserver driving the same keyframes via the Web Animations API. The polyfill route adds real maintenance weight: you're now running two animation systems and need to keep their timing curves in sync. I only reach for it when the scroll effect is core to the brand experience, not a nice-to-have.
Does this replace the JavaScript Scroll Timeline API?
No, and they solve different problems. The ScrollTimeline interface exposes the same underlying mechanism to JavaScript, which matters when:
- You need to construct the timeline dynamically (the scroller isn't known until runtime).
- You're animating canvas or WebGL content instead of DOM properties, where CSS keyframes can't reach.
- You need to read the current timeline progress for non-visual logic, like triggering analytics events at scroll milestones.
1// Attach a scroll-linked animation to a canvas-based chart,2// something pure CSS animation-timeline cannot target.3const chart = document.querySelector('#chart-canvas');45const timeline = new ScrollTimeline({6 source: document.querySelector('.chart-section'),7 axis: 'block',8});910chart.animate(11 [12 { transform: 'scale(0.8)', opacity: 0.4 },13 { transform: 'scale(1)', opacity: 1 },14 ],15 {16 timeline,17 fill: 'both',18 }19);If you're only animating DOM elements with standard CSS properties, stick to the CSS syntax. It's shorter, it's declarative, and browser devtools (Chrome's Animations panel in particular) visualize CSS-driven timelines far better than ones constructed in script.
Common mistakes that cost people a full afternoon
Forgetting will-change guidance doesn't apply the same way here. Because the browser owns the timeline calculation, you don't need will-change: transform as a performance hint the way you did for scroll-listener animations. Adding it anyway rarely hurts, but it's not the fix if your animation stutters. Stutter usually means the animated property itself is expensive (animating width or top instead of transform), not a timeline problem.
Nested scrollers with scroll() and no explicit scroller argument. animation-timeline: scroll() defaults to the nearest scrollable ancestor, which is not always what you expect inside a layout with overflow: auto containers. Be explicit: scroll(nearest), scroll(root), or name the scroller with scroll-timeline-name on the ancestor and reference it. I've debugged more "my animation just does nothing" reports from this than any other cause.
Assuming animation-range percentages are of the element's own height. They're not. Ranges are calculated against the element's transit through the scroller's visible region, which depends on both element size and viewport size. Test at multiple viewport heights, not just your 1440p monitor.
Reduced motion. Scroll-driven animations still need to respect prefers-reduced-motion. Wrap your @supports block in a media query check, or set animation-play-state: paused and jump straight to the end state for users who've opted out. The MDN guide on animation-timeline covers the full syntax if you need the property-level reference while building this out.
A workflow that actually catches problems before launch
Build the effect first with animation-timeline: scroll(root) set globally in devtools using Chrome's Animations panel, which lets you scrub the timeline manually instead of physically scrolling the page fifty times. Once the range and easing feel right, narrow it to view() if the effect should be per-element rather than page-wide.
Then test in this order: Chrome desktop, Chrome Android (touch scroll momentum behaves differently and can blow past your animation-range in one gesture), Firefox, and finally your @supports fallback with Safari or a browser flag disabled. If the fallback looks acceptable on its own, you've built a page that degrades honestly instead of breaking silently, which is the actual bar for shipping this feature in 2026.
Related Articles

The @starting-style Rule: Triggering Entry Animations in Pure CSS
An element that pops fresh into existence has no previous computed style for a transition to reference. @starting-style gives the browser that missing starting point, in pure CSS.

CSS @property: The Missing Piece for Smooth Property Animations
A custom property is just an untyped string until you explicitly register it with the browser. Without @property, an animated angle or gradient color snaps instead of tweening through frames.

View Transitions API: Effortless Page Animations Without JavaScript
One JavaScript call, or none at all for cross-document navigations, replaces hand-rolled FLIP animations built from getBoundingClientRect() and transforms.