Skip to content
Zumkai

CSS scroll-driven: what you can already use with no JS

Two of the three engines shipped scroll-driven animations. The third sits behind a flag. What that changes about what you can use in production today.

  • css
  • motion
Card with the support status of scroll-driven animations across the three browser engines in August 2026.
Contents
  1. What the real support looks like today
  2. scroll() or view(): which one you want
  3. The trap that breaks with no warning
  4. What degrades on its own and what breaks badly
  5. The @supports that solves it
  6. When IntersectionObserver stays the answer
  7. What "compositor thread" changes in practice
  8. Complete recipe: a reveal that works in all three engines
  9. When it fails to animate: the checking order
  10. Frequently asked questions
  11. Sources

You can use it in production today, in Chrome, Edge and Safari. You cannot retire IntersectionObserver, because Firefox still ships the feature behind a flag.

That distinction is no pedantry. It decides whether your effect becomes a silent degradation or an invisible section in a whole engine, and most texts on the subject today recommend the very path that produces the second case.

This guide is the map of what is already safe, what needs an explicit fallback, and the syntax trap that breaks the animation with no console error at all.

What the real support looks like today

Two of the three engines shipped. The third implemented it and left it off.

EngineStateSince
Chrome / Edge (Blink)Shipped, no flag115, July 2023
Safari (WebKit)Shipped26, September 2025
Firefox (Gecko)Behind a flagDefault in Nightly alone
Timeline of scroll-driven animation support by engine Blink shipped in July 2023, WebKit in September 2025 with improvements in 2026, and Gecko remains behind a flag in August 2026. 2023 2025 2026 Blink 115 · Jul 2023 WebKit Safari 26 · Sep 2025 26.4 compositor Gecko behind a flag · Nightly only
Solid line = shipped with no flag. Dashed line = implemented, off by default. Sources: MDN, WebKit, Bugzilla Mozilla, accessed 19 August 2026.

Safari stopped nowhere near its debut. 26.4 brought threaded scroll-driven animations. And 26.5, from 11 May 2026, brought four reliability fixes, among them support for the scroll range name and a bug where the animation failed to pause when animation-play-state turned paused at runtime (WebKit, accessed 19 August 2026).

In Firefox, the meta bug 1676780, Implement scroll-driven animations generated by CSS, stays at status NEW, open for five years and still holding 15 active dependencies. The Nightly activation landed through 1817303, RESOLVED FIXED, targeting Firefox 136. Meaning: Nightly turns the preference on from 136 onward, and the implementation meta never closed. Anyone wanting to test today turns on layout.css.scroll-driven-animations.enabled in about:config.

That is why MDN stamps the property as "Limited availability — this feature is not Baseline because it does not work in some of the most widely-used browsers" (MDN, accessed 19 August 2026).

Translated into a project decision: use it as a layer, not as a foundation.

scroll() or view(): which one you want

They are two different timelines, and swapping one for the other is the most common error.

scroll() measures the scroll container's progress. The animation moves as the scrollbar moves, from top to bottom. It serves a reading progress bar, a position indicator, anything tied to the whole document.

css
.progress-bar {
  animation: fill linear;
  animation-timeline: scroll(y root);
}

@keyframes fill {
  from { scale: 0 1; }
  to   { scale: 1 1; }
}

scroll() takes an axis (block, inline, x, y) and a scroller (root, nearest, self).

view() measures an element's progress crossing the viewport. The animation moves as that element enters and leaves view. It serves a reveal, per-item parallax, anything tied to a component.

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

@keyframes reveal-in {
  from { opacity: 0; translate: 0 2rem; }
  to   { opacity: 1; translate: 0 0; }
}

animation-range is what gives fine control. The available range names are entry, entry-crossing, contain, cover and exit. In the example above, the animation starts when the card touches the viewport and ends once it has covered 40% of the crossing, rather than at the end of it.

When you need an element animated by the scrolling of another, name the timeline:

css
.section    { view-timeline: --section block; }
.caption    { animation: slide linear; animation-timeline: --section; }
.container  { timeline-scope: --section; }

timeline-scope exists because a named timeline is visible only to descendants of whoever declared it. Without it, sibling elements see nothing of each other.

The five range names

animation-range is the part that pays most and gets explained least. The five names describe different moments of the element's crossing through the viewport:

NameCovers
coverFrom the first edge entering to the last leaving. The complete range
entryThe entrance alone: from the edge touching the viewport to sitting entirely inside
exitThe exit alone: from starting to leave to disappearing
containThe period where the element is entirely visible
entry-crossingThe edge crossing during the entrance

In practice, entry solves almost every reveal, and cover serves parallax that has to last the whole crossing. Mixing the two into one range (entry 0% cover 40%) starts at the entrance and ends mid-crossing, which tends to be the most natural timing for reading content.

The trap that breaks with no warning

animation-timeline is reset-only inside the animation shorthand. Meaning: writing the shorthand after erases the timeline and returns it to auto, without a word.

css
/* BROKEN — the timeline turns to auto and the animation runs on time */
.card {
  animation-timeline: view();
  animation: reveal-in linear;
}

/* CORRECT — declare the timeline after the shorthand */
.card {
  animation: reveal-in linear;
  animation-timeline: view();
}

No console error appears. The animation runs as an ordinary time-based animation instead, fires on load, and you spend twenty minutes hunting for what is wrong in the @keyframes.

Two smaller rules from the same family, also documented by MDN: when two timelines share the same <dashed-ident> and the same specificity, the last one declared in the cascade wins; and when there are fewer animation-timeline values than animation-name values, the timeline values repeat.

What degrades on its own and what breaks badly

Here is the table that decides whether you need a fallback. The question is always the same: how does the element look if the timeline never advances?

EffectWith no supportNeeds a fallback?
Reading progress barStays at 0%, invisible or emptyNo, it is decoration
Background parallaxStatic backgroundNo
Shadow or color changing on scrollStays in the initial stateNo
Decorative rotation or scaleStays in the initial stateNo
Reveal with opacity: 0Content invisible foreverYes, mandatory
Entrance with a large translateContent off screenYes, mandatory
Item with visibility: hiddenContent inaccessibleYes, mandatory

The pattern is clear: an effect that starts from the final state degrades on its own; an effect that starts from a hidden state leaves the content hidden.

And the second group is the very thing tutorials use as their opening example, because a reveal is the flashiest effect. Copying that snippet without a fallback is publishing a site whose sections fail to appear in Firefox.

The @supports that solves it

The rule is writing the CSS assuming no support exists, and only then switching the animation on.

css
/* Default state: visible. It works in any engine. */
.card { opacity: 1; translate: none; }

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

  @keyframes reveal-in {
    from { opacity: 0; translate: 0 2rem; }
    to   { opacity: 1; translate: 0 0; }
  }
}

The point that makes it work: the opacity: 0 lives inside the @keyframes, and the @keyframes lives inside the @supports. Anyone without support never meets the rule that hides the content.

The common error is the inverse, meaning leaving .card { opacity: 0 } in the base CSS and trying to undo it in @supports not. It works, and it depends on your remembering to undo everything, and one forgotten property becomes invisible content.

Respecting whoever asked for less movement also pays off:

css
@media (prefers-reduced-motion: reduce) {
  .card { animation: none; }
}

When IntersectionObserver stays the answer

It has nothing to do with support. Some things scroll-driven animations do in no way, by definition.

When you need to run code, not animate. Firing analytics, loading an image, starting a video, marking an item as read. The CSS timeline animates properties; it calls no function.

When the effect is one-way. Scroll-driven is reversible by nature: scroll back up, it undoes. If you want an item to appear and stay, IntersectionObserver with unobserve is simpler than any combination of animation-fill-mode and range.

When the trigger is not geometric. A counter that starts when the element appears, a lazy-load with a custom margin, a sticky header that changes state past a specific threshold.

When support has to be universal today. A corporate site, a checkout, a form. There Firefox is no detail.

The combination I use: CSS for what is decorative and reversible, IntersectionObserver for what triggers behavior.

The choice between a library and pure CSS has a decision tree in GSAP, Motion or pure CSS.

What "compositor thread" changes in practice

It changes the smoothness when the main thread is busy.

A scroll animation built in JavaScript runs on the main thread. It competes with React hydration, JSON parsing, an event handler and any third-party script. Once the main thread stalls for 200 ms, the animation stalls with it, which is the origin of that scroll that "stutters" on a heavy page.

Safari 26.4 moved scroll-driven animations to the compositor thread, giving them the same treatment transitions and keyframes already had. Chrome has done it since shipping.

The gain lies in the animation staying smooth while the rest of the page is slow, which is the exact moment the JavaScript version fails.

An honest caveat: not every animated property goes to the compositor. Animating opacity and transform stays there; animating width, height or top forces layout on the main thread and hands the problem back. The choice of property keeps mattering as much as before.

If the worry is the metric Google measures, worth knowing that scrolling enters no INP measurement, since the cost shows up at another moment.

Complete recipe: a reveal that works in all three engines

It brings everything together. Copyable.

css
/* 1. Base: content visible, depending on nothing */
.reveal {
  opacity: 1;
  translate: none;
}

/* 2. Scroll-driven layer, only where support exists */
@supports (animation-timeline: view()) {
  .reveal {
    animation: reveal-up linear both;
    animation-timeline: view();          /* after the shorthand, always */
    animation-range: entry 10% cover 35%;
  }

  @keyframes reveal-up {
    from { opacity: 0; translate: 0 1.5rem; }
    to   { opacity: 1; translate: 0 0; }
  }
}

/* 3. Respects whoever asked for less movement */
@media (prefers-reduced-motion: reduce) {
  .reveal { animation: none; opacity: 1; translate: none; }
}

In Chrome, Edge and Safari the content enters with the scroll. In Firefox it appears as usual, with no animation and no hole. In any engine with prefers-reduced-motion, it appears straight away.

No JavaScript, no library, no observer.

The complete catalog of motion techniques for the web covers the other approaches, the ones that still demand JavaScript included.

When it fails to animate: the checking order

Scroll-driven fails in silence. No exception, no console warning, since the animation never happens at all, or happens at the wrong moment. Checking in this order saves time.

1. The order in the shorthand. It is the most common cause, by far. animation-timeline has to come after animation. If it sits before, the timeline got reset to auto.

2. Does the engine support it? Put a visible detector in place while developing:

css
@supports not (scroll-timeline: --test) {
  body::before {
    content: "This browser supports no scroll-driven animations.";
    position: fixed; inset-block-start: 0; inset-inline-start: 0;
    padding: .5rem 1rem; background: #C4552F; color: #fff; z-index: 9999;
  }
}

If the banner appears in Firefox and never in Chrome, the CSS is right and the problem is support, not syntax.

3. Is the scroller the one you think it is? scroll() with no argument uses the nearest scroller. If an ancestor has overflow: hidden or overflow: auto, it becomes the scroller, and since it never scrolls, the timeline never advances. Test with scroll(root) to isolate it.

4. Is the named timeline in scope? A named timeline sees descendants alone. For siblings, timeline-scope on the common ancestor.

5. Does the element have height? view() needs the element to have a dimension so it can cross the viewport. An element with height: 0 or collapsed never completes the range.

6. Is prefers-reduced-motion active? If you wrote the accessibility rule, it is working. Check the system before suspecting the CSS.

7. Did you update the framework? Next.js 16 stopped overriding scroll-behavior: smooth during route navigation. It breaks no scroll-driven animation, and it changes the navigation behavior on a site with global smooth scroll.

A scroll-driven animation runs on the compositor and passes through no JavaScript, so the media query alone controls it. Doing that without delivering a static page sits in prefers-reduced-motion.

For a typographic effect, checking first whether the case fits clip-path or background-clip pays off, and both sit in text animation.

Frequently asked questions

Can I use it in production today?

Yes, as a progressive enhancement layer. Chrome, Edge and Safari cover most Brazilian traffic, and Firefox degrades well as long as you use the @supports pattern above. The failure mode is treating it as a substitute for IntersectionObserver in an effect that hides content.

Do I need a polyfill?

I recommend against it. A scroll-driven animation polyfill hands the work back to the main thread, which is the exact problem the native feature solves. You pay the performance cost to gain support in an engine that already degrades well enough.

Does this replace GSAP and ScrollTrigger?

For a reveal, simple parallax and a progress bar, yes. For a multi-stage timeline, section pinning, coordinated scrubbing between elements and a sequence with easing control per segment, no. The motion patterns I reverse-engineered on agency sites almost always land in that second group. The two tools coexist well: CSS for what is simple and reversible, GSAP for what is orchestration.

Why does my animation fire on its own at load?

Almost always the order in the shorthand. If animation: name linear appears after animation-timeline, the timeline gets reset to auto and the animation runs on time. Move animation-timeline after the shorthand.

Sources

Verified on 19 August 2026.

Review trigger: this post needs revisiting when (a) Firefox turns the flag on by default in a stable release, which moves the feature into Baseline and changes the central recommendation, (b) MDN alters the Limited availability stamp, or (c) a syntax change lands in animation-range or timeline-scope.