Every "scroll progress bar" or "fade in as you scroll" effect I've built before this needed a scroll event listener, some throttling logic to avoid firing it fifty times a frame, and usually requestAnimationFrame to keep the actual DOM writes off the hot path. animation-timeline replaces all of that with a CSS property.
TL;DR:
animation-timeline: scroll()orview()ties a CSS animation's progress to scroll position instead of a time duration, running entirely on the compositor thread with no JavaScript.scroll()tracks a scroll container's own position;view()tracks how far a specific element has traveled through the viewport. Both replace the common cases that used to need a throttled scroll listener.
Scroll Progress Bar: The scroll() Timeline
.progress-bar {
position: fixed;
top: 0;
left: 0;
height: 4px;
background: #2563eb;
transform-origin: left;
animation: grow-progress linear;
animation-timeline: scroll(root);
}
@keyframes grow-progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
scroll(root) ties the animation's progress directly to how far the document itself has scrolled, 0% at the top, 100% at the bottom. animation-timeline replaces animation-duration's time-based clock entirely, linear here describes the easing curve mapped onto scroll progress, not a duration in seconds, there's no duration to set because scroll position is the clock now.
Fade-In-On-Scroll: The view() Timeline
The more common effect, a card or section fading and sliding into view as the user scrolls to it, uses view() instead, which tracks the target element's own position in the viewport rather than the page's overall scroll position:
.card {
animation: fade-slide-in linear both;
animation-timeline: view();
animation-range: entry 0% cover 30%;
}
@keyframes fade-slide-in {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
animation-range: entry 0% cover 30% scopes the animation to a specific window of the element's journey through the viewport, starting the instant it enters (entry 0%) and finishing once it's 30% into being fully covered by the viewport. Without an explicit range, the animation runs across the element's entire visible-to-invisible transit, which is usually too gradual for a fade-in that should feel complete shortly after the element appears.
Why This Runs on the Compositor Thread
A scroll event listener executes JavaScript on the main thread every time it fires, and the main thread is also where layout, style recalculation, and your app's own JavaScript compete for time. Under load, that's exactly the kind of work that shows up as dropped frames during scroll. animation-timeline-driven animations are evaluated by the browser's compositor, the same subsystem that handles native scrolling and doesn't wait on the main thread, so a busy JavaScript task elsewhere on the page doesn't cause the scroll animation to stutter.
A Horizontal Gallery With Snap and a Scroll-Tied Indicator
Combining scroll() with an existing scroll-snap gallery gives you a progress indicator for free, without tracking which slide is active in JavaScript:
.gallery {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
}
.gallery-indicator {
animation: slide-indicator linear;
animation-timeline: scroll(x); /* tracks the gallery's own horizontal scroll */
}
@keyframes slide-indicator {
from { transform: translateX(0); }
to { transform: translateX(calc(100% - 20px)); }
}
scroll(x) here needs to be declared on an element whose nearest scrollable ancestor is the gallery itself (via scroll-timeline-name on the container in stricter setups), the shorthand shown works when the indicator lives inside the scrolling container. This is the kind of interaction that used to require an IntersectionObserver or scroll listener just to know which slide index was active, now it's derived directly from scroll position with no JavaScript state at all.
Browser Support and the Fallback Story
Scroll-driven animations reached Baseline availability across Chrome, Edge, and Firefox through 2025, with Safari following in early 2026. For the small remaining gap, the animation simply doesn't run in unsupported browsers, the element renders in its default (usually final) keyframe state rather than throwing an error, which for most fade-in effects means content is just visible immediately rather than animated in, a reasonable and non-broken fallback.
Old Way vs Modern Way
| Effect | Before | With animation-timeline |
|---|---|---|
| Scroll progress bar | Scroll listener + manual scaleX write | animation-timeline: scroll(root) |
| Fade-in on scroll into view | IntersectionObserver + class toggle | animation-timeline: view() |
| Active-slide indicator | Scroll listener computing index | animation-timeline: scroll(x) on the container |
Conclusion
animation-timeline doesn't add new animation capabilities, it changes the clock an animation runs against, from elapsed time to scroll position, and moves the whole calculation off the main thread in the process. If you're maintaining a scroll listener today whose only job is toggling a class or writing a transform based on scroll percentage, that's very likely a candidate to become CSS instead.