Skip to content
All posts
#CSS#Animation#Performance#Architecture

Scroll-Driven Animations Without a Scroll Listener

6 min read

CSS scroll-driven animations swap an animation's clock timeline for scroll position: how animation-timeline, scroll(), view(), and animation-range work, and where the support floor sits.

Scroll-linked UI used to mean a scroll listener, a requestAnimationFrame guard, and a hope that the main thread stayed free. CSS scroll-driven animations replace that apparatus with a timeline swap: they animate property values along a scroll-based timeline rather than the default time-based document timeline.[2]

Two kinds of timeline, and one thing they are not

The specification defines exactly two timeline types.[12] Scroll progress timelines are linked to the scroll progress of a particular scroll container, and view progress timelines are linked to the view progress of a particular box through a scrollport.[12]

The spec is explicit that these are not scroll-triggered animations, which are triggered by a scroll position but whose progress is driven by time.[12] A scroll-driven animation is linked to the scroll position of a scroll container, so scrubbing backwards reverses the animation and stopping the scroll freezes it in place.[1]

Attaching an animation to a timeline

The animation-timeline property specifies the timeline used to control the progress of a CSS animation.[3] By default that timeline is the time-based DocumentTimeline, which is why an existing keyframe animation ignores scroll entirely until you opt in.[3]

There is one ordering trap worth internalizing. Because animation-timeline is reset-only inside the animation shorthand, you need to declare animation-timeline after declaring any animation shorthand for it to take effect.[3]

scroll() for the whole page

The scroll() function creates an anonymous scroll progress timeline and is applied through animation-timeline.[4] With no arguments it references the block axis of the nearest ancestor scroll container, and the scroller and axis can be given in any order, so scroll(root block) tracks the document scroller vertically.[4]

A timeline also needs something to scroll.[4] If the indicated axis does not contain a scrollbar, then the animation timeline will be inactive, which surfaces as a zero-progress animation rather than an error you can catch.[4] A reading-progress bar is the canonical use, and the CSS is barely more than a keyframe and two declarations:

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

.progress {
  position: fixed;
  inset-block-start: 0;
  inset-inline: 0;
  height: 3px;
  background: currentColor;
  transform-origin: 0 50%;
  animation: grow-progress linear;
  animation-timeline: scroll(root block);
}

Note that no duration appears in that animation shorthand, and that is deliberate. Because an animation-duration set in seconds does not make sense when using a scroll progress timeline, you must set animation-duration to auto.[1] Omitting the duration entirely gets you the same default, which is why the shorthand above is shorter and correct at the same time.[1]

view() for reveal-on-entry

A view progress timeline progresses based on changes in the visibility of a subject element inside its nearest scroll container, and view() is how you create one without naming anything.[5] By default, view() uses the block axis.[5]

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

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

One constraint is worth knowing up front: it is not possible to determine the scroller of a view timeline, as it always tracks the subject within its nearest parent scroller.[1] You cannot point view() at an arbitrary ancestor the way you can with scroll(root).

The distinction is the one that trips people up in review. scroll() measures how far the container has scrolled; view() measures how far the subject has travelled through the scrollport.[5] Reveal effects want the second one.[5]

Range is the part most people get wrong

animation-range is the shorthand that sets the start and end of an animation's attachment range along its timeline.[6] By default, animations are only applied between the timeline's range start and range end, and the fill mode decides what happens outside that window.[6] Setting animation-fill-mode to backwards, forwards, or both applies the first keyframe styles until range start, the last keyframe styles after the animation ends, or both before and after.[6]

Range names let you describe that window in human terms instead of raw percentages.[9] By default, view progress timelines track elements across the entire scrollport, from the moment the start edge crosses in to the moment the end edge leaves.[9] The animation only occurs when the subject is visible within its scrollport, and if scrolling stops while the element is in view, the animation pauses.[9]

The range name can also stand alone, which is the detail most people miss: animation-range: entry 0% entry 100% can be rewritten to the much shorter animation-range: entry, because the omitted percentages default to the ends of that named range.[1]

That is why the shorthand reads so well for a card reveal: it runs while the card enters and is finished by the time the card is fully in view, rather than continuing to animate while the reader is trying to read it.

Insetting the visibility range

view-timeline-inset is used to specify one or two values representing an adjustment to the position of the scrollport in which the subject element is deemed to be visible.[7] Reach for it when a sticky header covers the top of the viewport and your reveal fires too early, or when you want a reveal to complete before an element reaches the fold:

css
.card {
  animation: reveal linear both;
  animation-timeline: view();
  animation-range: entry;
  /* start the reveal once the card is past the sticky header */
  view-timeline-inset: 4rem auto;
}

Named timelines and timeline-scope

Sometimes the element driving the animation is not an ancestor of the element being animated.[8] By default a named timeline, declared with scroll-timeline-name or view-timeline-name, can only be set as the controlling timeline of a direct descendant element.[8] timeline-scope modifies the scope of a named animation timeline, widening it so an ancestor can be driven by a timeline declared on a descendant.[8]

Why the declarative version is faster

The classic way to achieve these effects is to respond to scroll events on the main thread, which leads to two main problems: modern browsers perform scrolling on a separate process and therefore deliver scroll events asynchronously, and main-thread animations are subject to jank.[1]

JavaScript scroll-driven animations require scroll event listeners and IntersectionObserver objects on the main thread to track elements across the scrollport.[10] The declarative version can run off the main thread, driven by scroll, with a few lines of extra code.[1]

The support floor

Chrome and Edge shipped this at version 115, Safari at version 26, and Firefox's support is still listed as preview in MDN's compatibility data.[11] The Safari release notes confirm Safari ships support for scroll-driven animations, letting you tie animations to how far the user has scrolled.[13] MDN marks the module limited availability and states plainly that it is not Baseline because it does not work in some of the most widely-used browsers.[4]

So treat it as progressive enhancement. Feature-detect with @supports (animation-timeline: scroll()), and let the unsupported path keep a static, readable layout:

css
.progress { transform: scaleX(1); } /* static fallback: full width */

@supports (animation-timeline: scroll()) {
  .progress {
    transform: scaleX(0);
    animation: grow-progress linear;
    animation-timeline: scroll(root block);
  }
}

A reading indicator that simply sits at full width in Firefox is a better outcome than a bar stuck at zero.

Sources

[1] https://developer.chrome.com/docs/css-ui/scroll-driven-animations

[2] https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Scroll-driven_animations

[3] https://developer.mozilla.org/en-US/docs/Web/CSS/animation-timeline

[4] https://developer.mozilla.org/en-US/docs/Web/CSS/animation-timeline/scroll

[5] https://developer.mozilla.org/en-US/docs/Web/CSS/animation-timeline/view

[6] https://developer.mozilla.org/en-US/docs/Web/CSS/animation-range

[7] https://developer.mozilla.org/en-US/docs/Web/CSS/view-timeline-inset

[8] https://developer.mozilla.org/en-US/docs/Web/CSS/timeline-scope

[9] https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Scroll-driven_animations/Timeline_range_names

[10] https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Scroll-driven_animations/Timelines

[11] https://raw.githubusercontent.com/mdn/browser-compat-data/main/css/properties/animation-timeline.json

[12] https://drafts.csswg.org/scroll-animations-1

[13] https://web.dev/blog/web-platform-09-2025