I’ve wired up enough Intersection Observers to know exactly how much boilerplate they require. Create the observer, define the callback, set the threshold, observe every element, disconnect when done, handle the edge case where the element is already visible on mount. For a simple reveal animation. Every. Time.

CSS scroll-driven animations don’t replace everything — but they replace that.

The two new primitives

Two new functions link animations to scroll:

  • scroll() — ties an animation to the scroll position of a scroll container
  • view() — ties an animation to how much of an element is visible in the viewport

Both are values for animation-timeline. The key mental shift: instead of animation-duration, the scroll position itself becomes the timeline. Scroll down = animation progresses. Scroll up = it reverses.

Progress bar — zero JavaScript

The example everyone reaches for first, and still a good one:

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

.progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 3px;
  background: var(--accent);
  transform-origin: left;
  animation: grow linear;
  animation-timeline: scroll(root);
}

scroll(root) uses the document scroll as the timeline. No scroll listener, no scrollY math, no requestAnimationFrame. The bar just works.

Reveal on scroll with view()

This is where it gets actually useful. Elements that animate in as they enter the viewport:

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

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

animation-range: entry 0% entry 30% means the animation completes while the element goes from 0% to 30% visible in the viewport. After that, the element is fully revealed and stays there (both holds the final state).

Compare this to the Intersection Observer equivalent:

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      entry.target.classList.add('visible');
      observer.unobserve(entry.target);
    }
  });
}, { threshold: 0.1 });

document.querySelectorAll('.card').forEach(el => observer.observe(el));

Plus the CSS for .visible. Plus the cleanup. The CSS version is one block.

animation-range — controlling the trigger window

This is the property that makes view() actually useful in practice. Without it, the animation is tied to the full entry-to-exit journey of the element, which is usually not what you want.

/* Animate while element enters — most common */
animation-range: entry 0% entry 40%;

/* Animate while element is fully visible */
animation-range: contain 0% contain 100%;

/* Animate while element exits */
animation-range: exit 0% exit 100%;

/* Trigger only near the top of the viewport */
animation-range: entry 0% entry 20%;

The named ranges (entry, exit, contain, cover) map to stages of the element’s intersection with the scroll container. entry 0% is when the element first appears at the bottom of the viewport. entry 100% is when it’s fully entered. Think of it as a precision dial on when the animation fires.

Stagger without JavaScript

Because view() triggers per element, staggered reveals happen automatically — cards that enter the viewport later start their animation later. But you can also layer in an offset per element:

.card {
  animation: reveal linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 35%;
  /* Optional: push each card's trigger slightly later */
  view-timeline-inset: calc(var(--i) * -20px);
}

view-timeline-inset shifts when the element is considered “in view.” A negative value means the element needs to travel further into the viewport before the animation starts — effectively staggering cards in a horizontal row or grid where they’d otherwise all trigger at the same time.

Browser support

FeatureChromeFirefoxSafari
scroll() + view()115110 (flag)18
animation-range115110 (flag)18

Safari 18 (September 2024) shipped the full API. Firefox is still behind a flag in stable. For production, use @supports:

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

The non-@supports path gets the static version. The animated version layers on top for browsers that support it. Clean progressive enhancement — the page works without the animation, it’s just better with it.

What this replaces — and what it doesn’t

BeforeAfter
Intersection Observer + class toggleanimation-timeline: view()
Scroll event + scrollY mathanimation-timeline: scroll()
GSAP ScrollTrigger for basic revealsanimation-range: entry

For complex sequencing, pinning, scrubbing through multi-step animations, or coordinating scroll with non-CSS effects — JavaScript libraries still have their place. GSAP ScrollTrigger does things the CSS API can’t. But the majority of scroll-based visual effects in real UIs are reveals and progress indicators, and those now have a native answer that’s faster to write and easier to maintain.