Stop Listening to scroll for Progress Bars. CSS Scroll Timelines Are Baseline
TL;DR: animation-timeline: scroll() and view() drive a normal CSS animation with scroll position instead of time. Firefox 159 turned them on by default on October 27, 2026, so scroll-driven animations are now Baseline Newly available.
The listener everyone has written
You want a reading progress bar. A thin line at the top that fills as the reader scrolls. So you write this:
const bar = document.querySelector(".progress"); window.addEventListener("scroll", () => { const max = document.documentElement.scrollHeight - innerHeight; bar.style.transform = `scaleX(${scrollY / max})`; }, { passive: true }); Then someone resizes the window, and you add a resize listener. Then the bar stutters on a busy page, and you add requestAnimationFrame. Then a fade-in effect on cards shows up in the design, and you add an IntersectionObserver with ten thresholds. It works. It is also a small physics engine for a 4px line.
What it is
A CSS animation normally runs on the document timeline, which moves with time. animation-timeline lets you swap that for a timeline that moves with scrolling:
-
scroll()follows the scroll position of a scroll container, from 0% at the top to 100% at the bottom. -
view()follows one element as it travels through its scroll container's visible area.
Your keyframes stay exactly the same. Only the clock changes.
The minimal one: a reading progress bar
.progress { position: fixed; inset: 0 0 auto 0; height: 4px; background: rebeccapurple; transform-origin: 0 50%; animation: grow linear both; animation-timeline: scroll(root); } @keyframes grow { from { scale: 0 1; } to { scale: 1 1; } } No duration, no JavaScript. scroll(root) means "the document's own scroller", and the animation's progress is the scroll progress.
I tested it in Chrome 156 in a 400 by 300 px frame with a 1300 px tall page, so 1000 px of scroll. The computed scale was 0.25 1 at 250 px, 0.5 1 at 500 px, and 1 at the bottom. It maps one to one, which is exactly what the listener was trying to do.
The realistic one: reveal cards as they enter
The fade-in-on-scroll effect is where view() and animation-range earn their keep:
@media (prefers-reduced-motion: no-preference) { @supports (animation-timeline: view()) { .card { animation: reveal linear both; animation-timeline: view(); animation-range: entry 0% entry 100%; } } } @keyframes reveal { from { opacity: 0; translate: 0 40px; } to { opacity: 1; translate: 0 0; } } view() tracks the card through the viewport. animation-range: entry 0% entry 100% squeezes the whole animation into the entry phase: it starts when the card's top edge touches the bottom of the viewport, and ends when the card is fully inside.
In the same test frame, I placed a 100 px tall card 600 px down the page. At 300 px of scroll its top touched the bottom of the 300 px viewport, and its opacity was 0. At 350 px, halfway in, opacity was 0.5 and translate was 0px 20px. At 400 px, fully in, opacity was 1.
No observer, no thresholds, no class toggling. The wrappers keep it polite: motion only for people who did not ask for less, and only where the browser understands view().
Can I use it?
| Where | Status |
|---|---|
| Baseline | Newly available since October 27, 2026 |
| Chrome and Edge | 115 |
| Safari (macOS and iOS) | 26 |
| Firefox | 159 |
| Tested | Chrome 156, where every number in this post was measured |
| Spec | Scroll-driven Animations (W3C CSS Working Group) |
Firefox was the last one in. Its 159 release notes say scroll-driven animations are now enabled by default. MDN lists both animation-timeline and animation-range as Baseline 2026. If you still support older browsers, the @supports check above keeps them on the static design.
Gotchas
The
animationshorthand resets the timeline.animation-timelineis a reset-only part of theanimationshorthand, so writing the shorthand after it puts the timeline back toauto. Withanimation-timeline: scroll(root)first andanimation: grow linear bothsecond, Chrome reportedanimation-timeline: auto, a duration of0s, and a plainDocumentTimeline. The bar did not follow the scroll at all; it jumped to its last keyframe and stayed full width. Always putanimation-timelineafter the shorthand.scroll()picks the nearest scroller, andoverflow: hiddencounts. Plainscroll()meansscroll(nearest). I put the bar inside a fixed wrapper withoverflow: hidden, which makes that wrapper a scroll container with nothing to scroll. The timeline was inactive, and the computedscalestayednoneat every scroll position. For page-level effects, writescroll(root)and stop guessing.It is still an animation, so it still moves. Scroll-linked motion is exactly the kind of effect people turn on reduced motion to avoid. Keep decorative ones inside
@media (prefers-reduced-motion: no-preference), as in the card example. A progress bar that only changesscaleis usually fine to leave on.
Wrap-up
The scroll listener was never wrong, just a lot of code standing between a scroll position and a number between 0 and 1. Find one in your codebase, delete it, and give the keyframes a scroll timeline instead.
Sources
- Firefox 159 release notes for developers (MDN)
- Scroll-driven animations (web-features explorer, Baseline status)
- animation-timeline (MDN)
- animation-range (MDN)
This article was written with the help of AI and checked against the sources above.