DEV Community

JKC
JKC

Posted on

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 });
Enter fullscreen mode Exit fullscreen mode

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; }
}
Enter fullscreen mode Exit fullscreen mode

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; }
}
Enter fullscreen mode Exit fullscreen mode

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

  1. The animation shorthand resets the timeline. animation-timeline is a reset-only part of the animation shorthand, so writing the shorthand after it puts the timeline back to auto. With animation-timeline: scroll(root) first and animation: grow linear both second, Chrome reported animation-timeline: auto, a duration of 0s, and a plain DocumentTimeline. The bar did not follow the scroll at all; it jumped to its last keyframe and stayed full width. Always put animation-timeline after the shorthand.

  2. scroll() picks the nearest scroller, and overflow: hidden counts. Plain scroll() means scroll(nearest). I put the bar inside a fixed wrapper with overflow: hidden, which makes that wrapper a scroll container with nothing to scroll. The timeline was inactive, and the computed scale stayed none at every scroll position. For page-level effects, write scroll(root) and stop guessing.

  3. 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 changes scale is 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

This article was written with the help of AI and checked against the sources above.

Top comments (0)