Scroll-Driven Animation With CSS

A stylised red sun setting over layered blue mountains

For years, scroll animation meant JavaScript: scroll listeners, IntersectionObserver, or a library like GSAP ScrollTrigger doing the heavy lifting. That era is ending. CSS scroll-driven animations let the browser scrub a plain CSS animation with scroll position, natively and off the main thread. When I first wrote about animation-timeline it was a Chrome-only experiment. It is now shipped in three of the four major engines, it is an Interop 2026 focus area, and you can use it in production today as a progressive enhancement. Let’s take a proper tour.

Scroll-driven vs scroll-triggered

The name matters. A scroll-driven animation is scrubbed by scroll: the user’s scroll position is the playhead, and scrolling backwards runs the animation backwards. That is what CSS gives us. A scroll-triggered animation fires once when an element enters the viewport and then plays on its own clock. CSS does not do that part yet (it is being explored for a future level of the spec), so keep IntersectionObserver around for one-shot reveals. Everything below is about the scrubbed kind.

Why this beats JavaScript

Scroll listeners run on the main thread, and janky scroll handlers are one of the classic performance foot-guns. Scroll-driven CSS animations can run on the compositor thread, which means they stay in sync with scrolling even while the main thread is busy parsing, hydrating, or running your analytics tag’s fifth retry. You write a normal @keyframes animation, swap the clock for a timeline, and the browser does the rest.

Two kinds of timeline

There are two anonymous timeline functions, and they cover most use cases.

scroll(): scroll progress timelines

scroll() maps an animation to how far a scroll container has scrolled. The classic demo is a reading progress bar, and it is three lines of meaningful CSS:

@keyframes grow-progress {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

.progress-bar {
  transform-origin: 0 50%;
  animation: grow-progress linear;
  animation-timeline: scroll(root block);
}

scroll() takes two optional arguments: the scroller (nearest, root, or self) and the axis (block, inline, y, x). scroll(root block) means “the document’s vertical scroll,” which is what you want for a page progress bar.

view(): view progress timelines

view() maps an animation to an element’s journey across the scrollport: 0% as it enters one edge, 100% as it leaves the other. This is the one that replaces most IntersectionObserver reveal code:

@keyframes fade-up {
  from { opacity: 0; translate: 0 2rem; }
  to   { opacity: 1; translate: 0 0; }
}

.card {
  animation: fade-up linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 100%;
}

animation-range: the part the early articles missed

By default a view timeline runs across the element’s entire trip through the scrollport, which usually feels wrong: your fade-in is still half-finished when the element is mid-screen. animation-range restricts the animation to a slice of the timeline, and it is where scroll-driven animations start to feel designed instead of demoed. The named ranges:

  • cover: from first pixel entering to last pixel leaving (the default)
  • contain: only while the element is fully inside the scrollport
  • entry: while the element is entering
  • exit: while the element is leaving

You can mix names with percentages, so animation-range: entry 0% cover 40%; starts the moment the element appears and finishes at 40% of its journey. That one property is the difference between an animation that reacts and an animation that feels choreographed.

Named timelines and timeline-scope

The anonymous functions track the element’s own nearest scroller. When one element should drive an animation on a different element, give the timeline a name:

.gallery {
  scroll-timeline-name: --gallery;
  scroll-timeline-axis: inline;
}

.gallery-progress {
  animation: grow-progress linear;
  animation-timeline: --gallery;
}

The catch: the animated element must be a descendant of the element declaring the timeline for name lookup to work. When it is not, hoist the name with timeline-scope on a shared ancestor and the timeline becomes visible to the whole subtree:

.page-wrapper {
  timeline-scope: --gallery;
}

Gotchas worth knowing

  • Order matters. The animation shorthand resets animation-timeline, so declare animation-timeline after the shorthand or it will silently do nothing. This still bites everyone once.
  • Duration is ignored, mostly. The timeline drives progress, not the clock, so animation-duration has no visual effect. Some older WebKit builds wanted an explicit duration, so you will still see animation-duration: 1ms; in the wild as a harmless belt-and-braces line.
  • Respect reduced motion. Scrubbed motion can be worse than timed motion for vestibular disorders. Wrap the whole thing in @media (prefers-reduced-motion: no-preference).

Progressive enhancement with @supports

Feature detection is the same as ever:

@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .card {
      animation: fade-up linear both;
      animation-timeline: view();
      animation-range: entry 0% entry 100%;
    }
  }
}

Browsers without support show static, fully visible content. No cliff, no polyfill required. There is an official polyfill if you need identical behavior everywhere, but for decorative motion I would rather ship less JavaScript.

Browser support in mid-2026

  • Chrome and Edge: shipped since version 115, July 2023.
  • Safari: shipped in Safari 26, with threaded (off-main-thread) scroll animations and a batch of accuracy fixes landing through the 26.x point releases.
  • Firefox: still behind the layout.css.scroll-driven-animations.enabled flag in stable, on by default in Nightly, and named as an Interop 2026 priority. The flip feels close.

Global support sits a little over 80%, which is exactly the zone where progressive enhancement shines: the majority get the polish, everyone gets the content, and the Firefox flag flipping is a free upgrade you have already shipped.

Resources

Filed under: ,
Share

Leave a Reply

Your email address will not be published. Required fields are marked *