For a decade, "animate this as the user scrolls" meant reaching for JavaScript. You listened to the scroll event, measured element positions, did the math on every frame, and prayed your calculations stayed cheap enough not to jank the page. Libraries grew up around that pain. Most of them can now retire.

Scroll-driven animations move the whole job into CSS. You write a normal keyframe animation, then tell the browser to drive its timeline with scroll position instead of the clock. The work happens off the main thread, which means it keeps pace with the user's finger even while your JavaScript is busy elsewhere.

Two timelines, two different questions

Everything rests on a single idea: an animation's progress can be tied to something other than time. There are two sources to tie it to, and they answer different questions.

A scroll progress timeline answers "how far down this scroller are we?" Zero percent is the top, one hundred percent is the bottom. This is what you want for a reading-progress bar pinned to the top of an article.

.progress-bar {
  animation: grow linear;
  animation-timeline: scroll(root block);
}

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

The scroll() function names the scroller and axis. Here root means the document's scrolling element and block means the vertical axis. No JavaScript, no scroll listener, no layout thrashing.

A view progress timeline answers a more useful question for most effects: "where is this specific element within the viewport right now?" It tracks a single element as it enters at one edge and exits at the other. This is the one you reach for when you want cards to fade up as they scroll into view.

.card {
  animation: fade-in linear both;
  animation-timeline: view();
  animation-range: entry 0% cover 40%;
}

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

The part that trips everyone up

That animation-range line is where the real control lives, and it is the piece people miss. A view timeline has more than just "start" and "end" — it has named phases describing the element's relationship to the viewport.

entry covers the span where the element is crossing in at the leading edge. exit covers it leaving at the far edge. cover spans the entire time any part of the element is visible. contain covers only the time the element is fully inside the viewport. Once those names click, you can place an animation with real precision: "start fading in the moment the top edge appears, and finish once the element is forty percent of the way through covering the viewport" reads almost exactly like the code above.

What happens in a browser that doesn't support it

This is the reassuring part. If animation-timeline is not understood, the declaration is simply ignored, and a named animation with no timeline never runs — the element sits at its natural, un-animated state. So the safe pattern is to make the animated end state the default, and let the animation move from a hidden state to that default. A browser without support shows the finished layout immediately; a browser with support plays the reveal. Nobody sees a broken page.

If you want to be explicit rather than lean on that behaviour, feature queries work:

@supports (animation-timeline: scroll()) {
  .card { /* the animated version */ }
}

Where it stops

Scroll-driven animations are declarative, and that is their strength and their ceiling. They map scroll position onto animation progress and nothing more. If your effect needs to respond to velocity, to direction changes mid-scroll, or to arbitrary logic, you are back in JavaScript — but now for the genuinely dynamic ten percent, not the routine ninety. The reveal-on-scroll, the parallax band, the progress indicator, the sticky image that swaps as you pass it: those belong in CSS now.

The mental shift worth making is this. Stop thinking of scroll animation as something you compute and start thinking of it as something you declare. You are no longer running code on every frame. You are telling the browser which timeline drives the animation, and letting the compositor do what it is already very good at.