14 / blog / guideNovember 20268 min read

CSS scroll-driven animations, explained

Two CSS properties turn any @keyframes animation into one that follows the scroll: animation-timeline says what drives it, and animation-range says which stretch of the scroll plays it. Here is what view() and scroll() measure, what every range name means, which browsers run it, the fallback, and the jobs CSS still cannot do.

  • Guide
  • animation-timeline
  • animation-range
  • view()
Chrome, Edge
115+
Safari (macOS, iOS)
26+
Firefox
159+
scroll(root)0% → 100% of the page
view() card Acover: 200 → 1200
entry 0% cover 50%200 → 700
view() card Bcover: 1200 → 2200
Fig. 1 — one page’s scroll and the timelines CSS builds from it: scroll(root) covers the whole page, while each card’s view() runs only while it crosses the viewport. The thin bar is card A with animation-range: entry 0% cover 50%.
01The clock

From a clock to the scroll

A CSS animation normally runs on the document’s clock: it starts, plays for its duration, and ends. animation-timeline swaps that clock for a scroll position. The keyframes are the same; their progress, 0% to 100%, now comes from how far something has scrolled, forwards or backwards.

reveal.css11 lines
@keyframes fade-up {
  from { opacity: 0; translate: 0 40px; }
  to   { opacity: 1; translate: 0 0; }
}

.reveal {
  animation: fade-up linear both;
  /* After the shorthand, which resets both of these. */
  animation-timeline: view();
  animation-range: entry 0% cover 50%;
}

There is no duration: the scroll sets the pace, so the shorthand leaves it out. The order matters. The animation shorthand resets animation-timeline and animation-range to their defaults, so both go after it. both holds the first keyframe before the range starts and the last one after it ends, which is what keeps the card hidden until it arrives.

02Two timelines

view() or scroll()

scroll() follows a scroller’s position: 0% at the top, 100% at the bottom, the same for every element that uses it. That suits anything tied to the whole page, like a reading progress bar.

progress.css14 lines
.progress {
  position: fixed;
  inset: 0 0 auto;
  height: 4px;
  background: currentColor;
  transform-origin: 0 50%;
  animation: grow linear both;
  animation-timeline: scroll(root);
}

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

scroll() takes the scroller and the axis: nearest (the default, the closest scrolling ancestor), root (the page) or self, and block, inline, x or y. The scroll progress bar generator builds this bar on any edge, or as a ring, for the page or a single article.

view() follows one element’s trip through its scroller, from the first pixel in to the last pixel out. Every element gets its own timeline, which is why it is the one for reveals. Fig. 1 shows both on the same page: one long bar for scroll(root), and a short one per card for view().

When the element that moves is not the one that crosses the screen, name the timeline. view-timeline on a section makes a view() timeline that its descendants can use by name (scroll-timeline does the same for a scroller, and timeline-scope on a common ancestor lifts a name to elements outside it):

named.css8 lines
/* The section names a timeline; its heading animates on it. */
.section { view-timeline: --section; }

.section h2 {
  animation: fade-up linear both;
  animation-timeline: --section;
  animation-range: entry 0% entry 100%;
}
03animation-range

animation-range, name by name

A view() timeline has named stretches, and animation-range picks a start and an end on them, each a name and a percentage. For an element shorter than the viewport:

Range0% is when100% is whensvg-scroll-draw trigger
coverits top meets the bottom of the scrollerits bottom meets the top'top bottom' → 'bottom top'
entryits top meets the bottomits bottom meets the bottom: fully in'top bottom' → 'bottom bottom'
containits bottom meets the bottom: fully inits top meets the top: about to leave'bottom bottom' → 'top top'
exitits top meets the topits bottom meets the top: gone'top top' → 'bottom top'
entry-crossing and exit-crossing also exist; they differ from entry and exit only for an element taller than the viewport.

With no animation-range at all, a view() animation runs over the whole of cover. The ends do not have to share a name: entry 0% cover 50% starts as the element appears and finishes when its centre reaches the centre of the screen, the thin bar in Fig. 1. The scroll trigger visualizer shows the same arithmetic for the library’s edge-and-line triggers in the last column, with progress running 0 to 1 as you scroll.

To fade something in as it arrives and out as it leaves, put the range names in the keyframes themselves. One animation covers both ends, so nothing has to decide which of two animations wins in the middle:

in-and-out.css12 lines
/* Keyframes can name the range they belong to. */
@keyframes in-and-out {
  entry 0%   { opacity: 0; }
  entry 100% { opacity: 1; }
  exit 0%    { opacity: 1; }
  exit 100%  { opacity: 0; }
}

.card {
  animation: in-and-out linear both;
  animation-timeline: view();
}
04Browsers

Browser support and a fallback

BrowserSinceNotes
Chrome, Edge115Since July 2023: view(), scroll(), animation-range and named timelines. Opera, built on the same Chromium, has them too.
Safari (macOS, iOS)26Shipped with Safari 26. Earlier Safari has none of it.
Firefox159On by default since October 2026. Before that it sat behind a preference, off for most readers.

A browser without scroll timelines ignores animation-timeline and runs the animation on the clock, for no time at all, so the reveal above still ends up shown. A progress bar does not: it shows full from the start. Rather than rely on that, test for support and animate only when it is there:

reveal.css11 lines
/* Shown as written by default; animated only where scroll timelines
   exist, and only for readers who have not asked for less motion. */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .reveal {
      animation: fade-up linear both;
      animation-timeline: view();
      animation-range: entry 0% cover 50%;
    }
  }
}

If older browsers should still get a reveal, the usual one plays on a timer when an IntersectionObserver sees the element. The script marks the page first, so the hidden state only exists where something will undo it:

fallback.js12 lines
// No scroll timelines: reveal each element once, on a timer, instead.
if (!CSS.supports('animation-timeline: view()')) {
  document.documentElement.classList.add('io-reveal');
  const io = new IntersectionObserver((entries) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;
      entry.target.classList.add('is-in');
      io.unobserve(entry.target);
    }
  });
  document.querySelectorAll('.reveal').forEach((el) => io.observe(el));
}
fallback.css11 lines
@media (prefers-reduced-motion: no-preference) {
  .io-reveal .reveal {
    opacity: 0;
    translate: 0 40px;
    transition: opacity 0.6s ease-out, translate 0.6s ease-out;
  }
  .io-reveal .reveal.is-in {
    opacity: 1;
    translate: 0 0;
  }
}

The CSS scroll-driven animation generator writes the @supports version of seven effects and tests your browser live. A scroll reveal that plays on a timer, step by step, is the subject of how to animate text on scroll.

05Debugging

Why it is not working

Four causes cover most scroll-driven animations that do nothing.

The shorthand came last. animation after animation-timeline resets it, and the element simply appears:

wrong-order.css6 lines
.reveal {
  animation-timeline: view();
  /* Resets animation-timeline to auto: this now runs on the clock,
     for 0s, and the element just appears. */
  animation: fade-up linear both;
}

An ancestor hides overflow. overflow: hidden on any axis makes an element a scroll container, even when it cannot scroll, and view() follows the nearest one. In all three engines the animation then never runs and the element sits at its own style. overflow: clip clips without making a scroller:

overflow.css6 lines
/* Makes .page a scroll container. view() follows it, it never
   scrolls, and every reveal inside sits still. */
.page { overflow-x: hidden; }

/* Clips the same, and is not a scroll container. */
.page { overflow-x: clip; }

A range name on scroll(). entry, exit, cover and contain belong to view(). A scroll() timeline has none of them, so the browser ignores them and plays over the whole scroll.

No support. The @supports block is skipped, by design. Check with CSS.supports('animation-timeline: view()') in the console.

06Limits

When you still need JavaScript

A scroll timeline is a position, not an event, and that sets its limits. It cannot play once and stay: scroll back and it plays backwards. It cannot tell your code it finished, or hand its progress to a script that counts a number up. And it does nothing in a browser without support. For those, a library such as svg-scroll-draw runs the same animation on its own engine:

card.js7 lines
import { scrollAnimate } from 'svg-scroll-draw';

scrollAnimate('#card', {
  props: { opacity: [0, 1], transform: ['translateY(40px)', 'translateY(0px)'] },
  trigger: { start: 'top bottom', end: 'center center' }, // entry 0% → cover 50%
  once: true, // keeps the furthest point reached
});

With once, a custom window or a callback, scrollAnimate runs on JavaScript. Leave them out, keep to properties and easings CSS can write, and where view() exists, it writes the CSS from this post itself: @keyframes on animation-timeline: view() over cover 0% cover 100%, and the JavaScript engine everywhere else. The native CSS path post lists every condition, and the reference has the option that turns it off.

— / next

Build one and copy the CSS

Pick an effect, choose the range, scroll the preview, and copy the CSS with its @supports fallback, or the same animation as svg-scroll-draw code.

Star on GitHubFound this useful? A star helps others find it.

→Parts list

Keep reading

More from the blog