CSS · · 7 min read

Scroll-Driven Animations Without a Single Line of JavaScript

The Scroll-Driven Animations spec lets you tie CSS animations to scroll position or element visibility — and it runs off the main thread.

Every scroll effect I've built — reading progress bars, sticky nav transitions, fade-in-on-scroll cards — has needed JavaScript. Either a scroll event listener on window (which runs on the main thread and can cause jank), or an IntersectionObserver (better, but still wiring up callbacks, managing thresholds, and imperatively setting classes).

The Scroll-Driven Animations spec, now available in Chromium/Edge browsers and shipping to others, changes the model entirely. You define the animation in CSS. You tie it to a scroll timeline in CSS. The browser handles the rest — off the main thread.

The two timeline types

There are two kinds of scroll timelines:

  • scroll() — ties animation progress to the scroll position of a scrolling container. As you scroll down the page, the animation plays forward.
  • view() — ties animation progress to how much of an element is visible in the viewport. The animation plays as the element enters and/or exits view.

Example 1: Reading progress bar

This is the classic use case. A thin bar at the top of the page that fills as you scroll. With JavaScript, you'd listen to scroll events and update a width or transform. With Scroll-Driven Animations:

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

.progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 3px;
  background: oklch(0.7 0.2 220);
  transform-origin: left;

  animation: progress linear;
  animation-timeline: scroll();
}

That's it. No JavaScript. The animation-timeline: scroll() declaration tells the browser to use the scroll position of the nearest scrolling ancestor as the animation timeline. The browser maps scroll progress (0% to 100%) directly to animation progress (from → to).

Example 2: Element fade-in on scroll

The other common pattern: a card or section that fades in as it enters the viewport. This is where view() comes in.

@keyframes fade-up {
  from {
    opacity: 0;
    transform: translateY(1.5rem);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

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

animation-range: entry 0% entry 30% tells the browser to play the animation during the first 30% of the element's entry into the viewport. Once 30% of the way in, the animation is at its "to" state and stays there. The element reveals itself as it scrolls into view — exactly what you'd have used IntersectionObserver for.

The animation-fill-mode equivalent here is the both keyword in the animation shorthand. It keeps the element at the "from" state before it enters the viewport, and at the "to" state after the animation completes.

The performance story

This is the part I find most interesting. Scroll-Driven Animations run on the compositor thread, not the main thread. Traditional scroll event listeners run on the main thread — which means if your JavaScript is busy doing something else, scroll-driven effects can stutter.

IntersectionObserver helps, but it still fires callbacks on the main thread and you still have to imperatively update classes or styles. The browser can't predict what you're going to do in the callback, so it can't optimize it.

With Scroll-Driven Animations, the browser knows the entire motion upfront — it's all declared in CSS. The compositor can handle it independently of whatever JavaScript is doing on the main thread. The result is scroll effects that don't jank, even on pages with expensive JavaScript.

Browser support

As of early 2026, Scroll-Driven Animations are fully supported in Chrome 115+, Edge 115+, and Opera. Firefox is behind a flag (dom.animations-api.scroll-driven.enabled). Safari support is in development.

The graceful degradation here is that elements simply don't animate — they appear at their final state immediately. That's acceptable for most uses. For anything where the animation is essential to understanding the UI, add a @supports check.


I keep expecting a catch with this API. There isn't really one — it does what it says, it performs better than the alternatives, and the CSS is actually readable. Features like this one make me feel like the platform is genuinely moving in a good direction. Building things that felt like they needed a library a few years ago now just... don't.