DEV Community

Wilson Xu
Wilson Xu

Posted on

CSS Scroll-Driven Animations: The End of JavaScript Scroll Listeners

CSS Scroll-Driven Animations: The End of JavaScript Scroll Listeners

Scroll-driven animations used to require JavaScript. You'd attach a scroll event listener, calculate how far through the page the user had scrolled, and update a CSS property or animation timing function on every frame. It worked, but it was verbose, ran on the main thread, and couldn't benefit from browser optimizations like compositor-only properties.

That's changing. The CSS Scroll-Driven Animations specification — now shipping in Chrome 115+, Edge 115+, and Firefox 123+ — lets you write scroll-linked animations entirely in CSS, with no JavaScript required. The browser handles the animation timing based on scroll position, and because it's declarative, it can be optimized in ways that event-listener-based approaches never can.

This article covers the full scroll-driven animations API: how @scroll-timeline works, what you can animate with it, and the practical patterns that make it useful today.


The Core Concept: Tying Animation Progress to Scroll

The key idea is simple. Every CSS animation has a timeline — normally it's the document timeline, which runs from 0 to 100% as the page loads. With scroll-driven animations, you replace the document timeline with a scroll timeline, which runs from 0 to 100% as the user scrolls through a specific container or the root document.

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

.scroll-progress {
  animation: progress-bar linear;
  animation-timeline: scroll(root);
  transform-origin: left center;
}
Enter fullscreen mode Exit fullscreen mode

That's the entire implementation of a scroll progress bar. No JavaScript. The animation-timeline: scroll(root) binds the animation's progress to the root document's scroll position. As the user scrolls down, the progress bar grows from 0% to 100%.


@scroll-timeline: Naming and Configuring Your Scroll Timeline

The scroll() function used inline is the shorthand, but @scroll-timeline gives you more control. You define a named timeline once and reference it across multiple animations:

@scroll-timeline scroll-progress {
  source: auto;       /* or a specific element */
  orientation: block;  /* block (vertical), inline (horizontal) */
  scroll-padding: 100px; /* offset at end of scroll range */
}

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

.progress-bar {
  animation: progress-bar linear both;
  animation-timeline: scroll-progress;
  transform-origin: left center;
}
Enter fullscreen mode Exit fullscreen mode

The source property is particularly powerful. By default, scroll(root) uses the nearest scrollable ancestor or the document root. But you can target a specific scroll container:

@scroll-timeline reading-progress {
  source: selector(#article-body);
  orientation: block;
}
Enter fullscreen mode Exit fullscreen mode

This lets you create an animation tied to a specific scrollable element rather than the whole page — useful for carousel indicators, nested scroll stories, or reading progress within an article body.


Animating Anything: The Practical Patterns

Scroll-driven animations aren't limited to progress bars. Because the animation timeline is now scroll-driven, you can animate any CSS property that accepts animations. Here are the patterns that matter most.

Reveal-on-scroll

The classic scroll-reveal animation — elements fade and slide in as they enter the viewport — is now pure CSS:

@scroll-timeline reveal {
  source: auto;
  orientation: block;
  time-range: 0.4s;
}

@keyframes slide-in {
  from {
    opacity: 0;
    transform: translateY(40px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.reveal {
  animation: slide-in ease-out both;
  animation-timeline: reveal;
  animation-range: entry 0% entry 40%;
}
Enter fullscreen mode Exit fullscreen mode

animation-range controls when the animation plays within the scroll timeline. entry 0% means the animation starts when the element enters the viewport, and entry 40% means it finishes at 40% of the entry animation range. This creates a reveal effect that fires once and completes quickly — exactly the behavior you'd implement with Intersection Observer and JavaScript.

Sticky Progress Indicators

A common pattern: a tab or sidebar that shows which section you're currently reading. With scroll-driven animations and animation-iteration-count, you can create a ticking indicator that advances as you scroll through sections:

@scroll-timeline section-tick {
  source: auto;
  orientation: block;
}

@keyframes tick {
  from { clip-path: inset(0 100% 0 0); }
  to { clip-path: inset(0 0% 0 0); }
}

.section-1-indicator {
  animation: tick linear both;
  animation-timeline: section-tick;
  animation-range: contain 0% contain 25%;
}

.section-2-indicator {
  animation: tick linear both;
  animation-timeline: section-tick;
  animation-range: contain 25% contain 50%;
}
Enter fullscreen mode Exit fullscreen mode

The contain keyword constrains the timeline to only when the specified element is in view. When section 1 is scrolled into view, its indicator animates from 0 to 100%. When you scroll to section 2, section 1's indicator freezes while section 2's begins its animation.

Parallax Without JavaScript

Parallax scrolling — background layers moving slower than foreground layers — is one of the most requested scroll-driven effects. With scroll-driven animations, this is a single CSS property:

@scroll-timeline parallax-scroll {
  source: auto;
  orientation: block;
}

.hero-background {
  animation: parallax-shift linear both;
  animation-timeline: parallax-scroll;
  animation-range: entry entry;
}

@keyframes parallax-shift {
  from { transform: translateY(0); }
  to { transform: translateY(100px); }
}
Enter fullscreen mode Exit fullscreen mode

The key insight: animation-range: entry entry maps the animation to the element's entry into the scrollport. When the element is above the viewport, it's at the start of the timeline. When it reaches the top of the viewport (stops), it's at the end. Since translateY is a compositor-friendly property, this runs on the GPU and doesn't cause layout recalculations.

Dynamic Typography

Here's a pattern that really shows off the API: a hero headline that starts enormous and shrinks to a normal size as you scroll past it, like a traditional sticky header effect but without any JavaScript:

@scroll-timeline hero-collapse {
  source: auto;
  orientation: block;
}

.hero-title {
  animation: hero-resize linear both;
  animation-timeline: hero-collapse;
  animation-range: entry entry;
}

@keyframes hero-resize {
  from {
    font-size: clamp(3rem, 10vw, 8rem);
    opacity: 0;
  }
  50% {
    opacity: 1;
  }
  to {
    font-size: clamp(1.5rem, 4vw, 2.5rem);
    opacity: 1;
  }
}
Enter fullscreen mode Exit fullscreen mode

As the hero section scrolls out, the title animates from a large, fading-in display font to a smaller, solid heading. The animation progress is directly tied to scroll position — no JavaScript event handler required.

Scroll-Linked Carousel

A carousel where sliding to the next card is driven by scroll rather than click:

@scroll-timeline carousel-snap {
  source: selector(#carousel);
  orientation: inline;
}

.carousel-track {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-padding: 20px;
}

.card {
  scroll-snap-align: start;
  flex: 0 0 300px;
  scrollbar-width: none; /* Hide scrollbar */
}

.card-progress {
  animation: card-highlight linear both;
  animation-timeline: carousel-snap;
  animation-range: own-slide 0% own-slide 100%;
}

@keyframes card-highlight {
  from {
    box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.5);
    transform: scale(0.95);
  }
  to {
    box-shadow: 0 0 0 8px rgba(59, 130, 246, 0);
    transform: scale(1);
  }
}
Enter fullscreen mode Exit fullscreen mode

Understanding animation-range

The animation-range property is the most powerful and most confusing part of scroll-driven animations. It controls when an animation starts and ends relative to the scroll timeline.

animation-range: [ start-range ] [ end-range ]
Enter fullscreen mode Exit fullscreen mode

Common range names:

Range Name Meaning
cover 0% Element first enters scrollport
contain 0% Element's scroll-snap container starts
entry 0% Element enters viewport (equivalent to cover 0%)
exit 0% Element exits viewport
entry 100% Element has been fully scrolled into view
cover 100% Element has fully exited scrollport

You can also use percentage values directly:

animation-range: 0% 50%;  /* Animation plays for first half of scroll */
animation-range: 25% 75%;  /* Animation plays for middle half */
animation-range: 100% 100%; /* Fires at exact scroll position */
Enter fullscreen mode Exit fullscreen mode

And you can combine named ranges with percentages:

animation-range: entry 0% entry 40%; /* Starts at entry, ends 40% into entry */
Enter fullscreen mode Exit fullscreen mode

Browser Support and Progressive Enhancement

As of early 2026, scroll-driven animations are supported in Chrome/Edge 115+, Firefox 123+, and Safari 17.4+. The global browser support rate is around 85% — meaningful for many audiences, but you'll still need a JavaScript fallback for the remaining 15%.

The graceful degradation strategy is to use @supports:

/* Fallback: static final state, no animation */
.progress-bar {
  transform: scaleX(1);
}

/* Enhanced: animated progress via scroll */
@supports (animation-timeline: scroll()) {
  @scroll-timeline scroll-progress {
    source: auto;
  }

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

  .progress-bar {
    animation: progress-bar linear both;
    animation-timeline: scroll-progress;
    transform-origin: left center;
  }
}
Enter fullscreen mode Exit fullscreen mode

For JavaScript-dependent use cases, you can also detect scroll-driven animation support and fall back to Intersection Observer:

if (!CSS.supports('animation-timeline', 'scroll()')) {
  // Use Intersection Observer fallback
  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        entry.target.classList.add('in-view');
      }
    });
  }, { threshold: 0.4 });

  document.querySelectorAll('.reveal').forEach(el => observer.observe(el));
}
Enter fullscreen mode Exit fullscreen mode

Performance: Why CSS Beats JavaScript

The main reason to prefer scroll-driven animations over JavaScript scroll listeners is performance. A JavaScript scroll handler running on every scroll event (which can fire hundreds of times per second during fast scrolling) competes with your main thread for rendering time. Missed frames become jank.

Scroll-driven animations are handled by the browser's compositor thread. When you're animating only transform and opacity — as you should be with scroll-driven animations — the browser can update the animation without involving the main thread at all. The result is buttery 60fps animations even on low-powered devices, without any JavaScript running.

There's a specific list of CSS properties that can be animated in scroll-driven animations in a way that enables compositor-only treatment. The key ones: transform, opacity, clip-path, filter, and CSS custom properties (when used with @property and a type). If you stick to these properties, your scroll-driven animations will be GPU-accelerated.


Combining with View Transitions

The CSS View Transitions API pairs naturally with scroll-driven animations. You can use scroll-driven animations to control how elements animate during a view transition:

@keyframes slide-in {
  from { transform: translateX(-100%); }
  to { transform: translateX(0); }
}

::view-transition-old(root) {
  animation: 300ms ease-out both slide-out;
}

::view-transition-new(root) {
  animation: 300ms ease-out both slide-in;
  animation-timeline: scroll(root);
}

@keyframes slide-out {
  from { transform: translateX(0); opacity: 1; }
  to { transform: translateX(100%); opacity: 0; }
}
Enter fullscreen mode Exit fullscreen mode

This creates a page transition where the new page content is revealed as you scroll — the transition progress is controlled by scroll position rather than time.


Conclusion

CSS scroll-driven animations represent a fundamental shift in what's possible with pure CSS. Effects that previously required JavaScript event listeners, requestAnimationFrame, and careful performance optimization are now a few lines of declarative CSS. The browser takes care of timing, frame-rate management, and compositor optimization.

Start with the simple patterns — progress bars, reveal-on-scroll — and work up to the more complex ones — staggered section indicators, parallax layers. The animation-range property is the main learning curve, but once you understand how entry/exit/contain ranges work with scroll timelines, the creative space is enormous.

And critically: this runs off the main thread. Your animations won't compete with your event handlers, your React reconciliation, or your bundle's initialization code. For performance-sensitive applications, that's the real value of scroll-driven animations — not just that they're easier to write, but that they can be faster by default.

Top comments (0)