CSS Scroll-Driven Animations: Ship Them Safely

CSS scroll-driven animations tie a keyframe animation to scroll position instead of a clock. You set animation-timeline: scroll() to follow a scroll container’s progress, or animation-timeline: view() to follow an element moving through the viewport. Chrome, Edge and Safari support them. Firefox stable still needs a flag.

That last sentence is the whole planning problem. The API is pleasant, the performance story is real, and roughly a fifth of your visitors will not see any of it. So the question is not whether the syntax works, it is how you write the CSS so the non-supporting browsers get something sensible rather than a broken page.

What does animation-timeline actually do?

A normal CSS animation runs against the document timeline, which is time. animation-timeline swaps that source out for a progress value derived from scrolling.

There are two anonymous timeline functions, both documented in MDN’s scroll-driven animations guide. scroll() tracks how far a scroll container has scrolled, from 0% at the top to 100% at the bottom. It takes an axis (block, inline, x, y) and a scroller (nearest, root, self), so scroll(root block) follows the document’s vertical scroll regardless of where the animated element sits.

view() tracks a single element’s passage through the scrollport. It starts when the element’s edge crosses into view and finishes when it leaves. That is the one you want for reveal effects, because it is per-element and needs no observer.

@keyframes reveal {
  from { opacity: 0; transform: translateY(2rem); }
  to   { opacity: 1; transform: none; }
}

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

Order matters here, and it catches people out. animation-timeline is a reset-only sub-property of the animation shorthand, so writing the shorthand after it resets the timeline back to auto and your animation silently reverts to a time-based one. Timeline second, always.

Note also that there is no duration. On a scroll timeline, animation-duration: auto means “fill the whole timeline”, which is what you want, and it is also the source of the fallback trap below.

Which browsers support CSS scroll-driven animations?

Chrome and Edge shipped the feature in version 115, back in July 2023. Safari landed it in Safari 26.0 in September 2025, and Safari 26.4 followed up by moving scroll-driven animations onto the compositor thread.

Firefox is the hold-out. Support exists but sits behind the layout.css.scroll-driven-animations.enabled preference in stable, enabled by default only in Nightly. Firefox 152, released in June 2026, extended that flagged implementation with <timeline-range-name> values in @keyframes, so work is clearly ongoing.

As of September 2026, animation-timeline is therefore not Baseline. It is a named focus area in Interop 2026 alongside scroll-timeline and view-timeline, which is the strongest signal you will get that all four engines intend to converge. Plan for Firefox flipping the switch during 2026, and plan for shipping before it does.

How do you stop it breaking in Firefox?

Here is the failure mode. In a browser that does not understand animation-timeline, the declaration is dropped at parse time. Your animation declaration survives. It is now a plain time-based animation with no duration, auto resolves to 0s, and the animation completes instantly on page load. With animation-fill-mode: both the element sticks at the final keyframe, which is often survivable. Without it, the element snaps back to its unanimated state and your carefully faded-in card stays at opacity: 0 forever.

Do not rely on that behaviour either way. Write the finished state as your default CSS, then layer the animation on top inside a feature query:

.card {
  opacity: 1;
  transform: none;
}

@supports (animation-timeline: view()) {
  @media not (prefers-reduced-motion: reduce) {
    .card {
      animation: reveal linear both;
      animation-timeline: view();
      animation-range: entry 0% entry 100%;
    }
  }
}

Two things fall out of this for free. Firefox users get the content, unanimated, with no layout shift and no invisible elements. Anyone who has asked their operating system to reduce motion gets the same. That second point is not optional if you care about WCAG conformance: motion tied to scrolling is exactly the kind of thing that triggers vestibular symptoms.

When do you need a named timeline?

view() tracks the element it is declared on. scroll() tracks the nearest ancestor scroll container. If the thing you want to animate is not the thing you want to measure, you need a named timeline.

Reading progress bars are the obvious case. The bar lives in a sticky header; the progress you care about belongs to the <article>. Name a timeline on the article, hoist it into scope, and reference it by its dashed ident:

article {
  view-timeline: --article block;
}

body {
  timeline-scope: --article;
}

.progress-bar {
  transform-origin: left center;
  animation: grow linear both;
  animation-timeline: --article;
  animation-range: contain 0% contain 100%;
}

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

timeline-scope is the part people miss. A named timeline is normally visible only to descendants of the element that declares it, and a sticky header is a sibling of the article, not a child. Declaring timeline-scope: --article on a common ancestor widens the lookup.

For a whole-page progress bar you can skip all of that and use animation-timeline: scroll(root block).

What do the animation-range keywords mean?

animation-range decides which slice of the timeline your keyframes map onto, and it is where view timelines get their expressiveness. The named ranges are cover, contain, entry, exit, entry-crossing and exit-crossing.

cover is the full journey, from the moment the element’s leading edge touches the scrollport to the moment its trailing edge leaves. contain is the narrower window where the element is entirely inside. entry covers just the arrival, exit just the departure. Each takes a percentage or a length, and start and end can use different ranges, so animation-range: entry 25% cover 60% is legal and occasionally useful.

For reveals you almost always want something inside entry. entry 0% entry 100% finishes the animation the instant the element is fully visible, which reads as natural. cover 0% cover 100% spreads it across the entire scroll-past and tends to look sluggish.

Is it faster than IntersectionObserver?

Usually, and for a structural reason rather than a micro-optimisation one. A JavaScript reveal has to run a callback, mutate the DOM, and let style and layout recalculate, all on the main thread, while the user is scrolling. Scroll-driven animations are declared up front, so the compositor can drive them without asking the main thread anything. Chrome’s own documentation makes that the headline benefit, and Safari 26.4 added threaded scroll-driven animations for the same reason.

The caveat is the usual one. Only compositor-friendly properties stay off the main thread. Animate transform and opacity and you get the smooth path; animate width, height or margin and every frame forces layout, exactly as it would with a time-based animation. Scroll timelines do not launder an expensive animation into a cheap one.

If you are chasing responsiveness numbers specifically, removing scroll listeners is one of the more reliable wins available, because scroll handlers compete directly with the interactions you are being measured on.

What we would ship today

Reveal-on-scroll with view(), and progress indicators with scroll(root). Both are decorative, both degrade to a static finished state in one line of feature query, and both remove JavaScript that was doing a worse job. Put the @supports block in from the start rather than promising to add it later.

What we would not ship yet is anything load-bearing: a nav bar whose visibility depends on a scroll timeline, or an animation carrying information the user needs. Firefox stable is still a flag away, and a fifth of your traffic silently missing a decorative fade is a very different outcome from a fifth of your traffic missing a menu. Same rule we applied to CSS anchor positioning and to cross-document view transitions: enhance with it, do not depend on it.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch