Skip to content

Using animation-timeline for Scroll-Driven Animations

CSS scroll-driven animations use animation-timeline to tie an animation's progress to scroll position, replacing scroll-JS libraries for common effects.

· · 8 min read
Time-lapse photography of moving lights

Quick Take

I ripped out a scroll-listener library that existed for one thing: a progress bar tied to page scroll. Three lines of CSS do the same job now, and they don't run a callback on every scroll event.

Run this yourself. The benchmark behind the numbers below: scroll-driven-animation-cost/ in the Coding Dunia code-examples repo.

Scroll-driven animation is a CSS technique that ties an animation's progress to scroll position instead of elapsed time, using the animation-timeline property to swap the animation's clock entirely. 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.

Quick take: animation-timeline: scroll() or view() 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 cases that used to need a throttled scroll listener.

How Do You Build a Scroll Progress Bar With 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. Per MDN, scroll() accepts an optional axis argument and an optional scroller argument, scroll(nearest block) for example, which lets one progress bar track a specific nested scroll container instead of always defaulting to the whole document.

A thumb scrolling on a smartphone screen held in one hand
Photo by NordWood Themes on Unsplash

How Do You Fade In on Scroll With 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 is a CSS property that scopes a scroll-driven animation to a specific portion of the timeline it's tied to, rather than always running across the full 0 to 100 percent span. 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 Does This Run 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. According to Chrome for Developers, this is the same architectural reason CSS transforms and opacity changes have always been the cheap properties to animate, compositor-only properties never trigger a layout or paint pass, and scroll-driven timelines inherit that same performance ceiling by design rather than as an incidental benefit.

So much for the theory. I wanted the number, so I built the same progress bar twice on a 19,280 px page, once with a scroll listener and once with animation-timeline: scroll(root block), and had the page scroll itself in 200 steps while counting the time spent inside the handler.

Driven byHandler callsMain-thread ms in handler
scroll event listener2005.1 / 6.3 / 7.0
animation-timeline: scroll()00

Three runs, headless Chromium 151 on an Apple M1, measured 2026-08-24. The same page in a Linux container came in at 11.4 to 14.7 ms, which is worth stating rather than hiding: this number is a property of your machine, and a single run of anyone's benchmark, mine included, tells you less than you think.

Here's the part I'd rather say out loud than bury. Six milliseconds spread across an entire page scroll is not what makes a site feel broken. If you came here expecting me to tell you a scroll listener is a performance disaster, it isn't, and anyone selling you that headline is overselling it.

What's worth having is the shape of the difference, not its size on an idle page. The CSS version is exactly zero. No throttling, no requestAnimationFrame batching, no handler to keep cheap as the feature grows, and no competition with hydration or a React render on the one thread that can't be widened. Zero scales predictably in a way that "small, for now" never does.

One measurement detail, since a benchmark that can't tell success from silence is worthless: at the bottom of the page the CSS bar resolves to scaleX(1), which is the identity matrix, identical to an animation that never ran at all. The script samples the computed transform halfway down instead, where it read matrix(0.495021, 0, 0, 1, 0, 0). That's a live timeline, not a lucky default.

A quick three-step check to confirm an animation is actually running on the compositor and not silently falling back:

  1. Open DevTools' Performance panel and record a scroll interaction, a compositor-only animation shows no main-thread scripting spikes tied to it.
  2. Confirm the animated properties are limited to transform and opacity, animating layout-triggering properties like width or top can force main-thread work even under animation-timeline.
  3. Test in the oldest supported browser in your analytics and confirm the fallback (static final-keyframe state) looks acceptable, not broken.
Long-exposure light trails from city traffic streaming beneath illuminated skyscrapers at night
Photo by Getty Images on Unsplash

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. In my testing, replacing a hand-rolled gallery indicator with this approach removed roughly 40 lines of JavaScript, an IntersectionObserver setup, a debounced scroll handler, and the state management tying them together, while producing a smoother indicator motion than the throttled version ever managed.

A gallery room with several colorful abstract paintings mounted on the walls
Photo by Nigel Hoare on Unsplash

What Is the Browser Support and 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.

How Does This Compare to the Old Way?

Three of the most common scroll-tied effects, a progress bar, a fade-in, and an active-slide indicator, each used to need their own piece of JavaScript. Lining them up against the CSS-only equivalent shows how much of that code just goes away.

EffectBeforeWith animation-timeline
Scroll progress barScroll listener + manual scaleX writeanimation-timeline: scroll(root)
Fade-in on scroll into viewIntersectionObserver + class toggleanimation-timeline: view()
Active-slide indicatorScroll listener computing indexanimation-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.

Frequently Asked Questions

What is animation-timeline in CSS?
animation-timeline swaps a CSS animation's clock from time (the default, driven by animation-duration counting seconds) to scroll position. Set it to scroll() to tie the animation's progress to how far an element (usually the document itself, or a specific scrollable container) has scrolled, or view() to tie it to how far an element has scrolled through the viewport. The animation's keyframes stay the same, only what drives their progress changes.
Do scroll-driven CSS animations perform better than a scroll event listener?
Yes, meaningfully. A JavaScript scroll listener fires on the main thread on every scroll event, competing with everything else running there, and typically needs manual throttling or requestAnimationFrame batching to avoid jank. Scroll-driven CSS animations run on the compositor thread, the same one handling native scrolling, so they don't add main-thread work per scroll frame at all, which is why they stay smooth even on a busy page.
What is the difference between scroll() and view() timelines?
scroll() ties progress to the scroll position of a scroll container itself, 0% at the top, 100% at the bottom (or the relevant axis). view() ties progress to how far a specific element has traveled through the viewport, 0% when it enters, 100% when it exits, which is the one you want for 'animate this card as it scrolls into view' effects, since it doesn't matter where that element sits on the whole page.