Browse by section

Web Design 日本語

CSS Scroll-Driven Animations with animation-timeline

Scroll-driven animations are written with animation-timeline: scroll() and animation-timeline: view(). No JavaScript required, and the animation tracks scroll position exactly.

This article was published in 2024 and completely rewritten in September 2026. The code originally shown used an at-rule called @scroll-timeline, which was dropped from the specification and does not work in any browser. It has been replaced with the current syntax, along with support status and fallback guidance.

  • @scroll-timeline { source: ...; scroll-source: ...; } is invalid. It was an abandoned draft syntax—pasting it does nothing
  • The current specification uses the scroll-timeline / view-timeline properties together with animation-timeline

Sponsored

What a scroll-driven animation is

A normal CSS animation advances with time. Write duration: 1s and it plays over one second.

A scroll-driven animation replaces that basis with scroll position. Stop scrolling and it stops; scroll back and it rewinds.

Normal animation Scroll-driven
Driven by Time Scroll distance
Can it pause No Stops when scrolling stops
Rewind No Scrolling up reverses it
duration Required Ignored

There are two kinds of timeline, and choosing between them is the first decision:

  • scroll(): progress through a scroll container. Reading-progress bars
  • view(): progress of an element crossing the viewport. Per-element reveals

Support, and why fallbacks matter

This is not Baseline as of September 2026.

Browser Support
Chrome / Edge 115 and later
Safari 26
Firefox Disabled by default (behind layout.css.scroll-driven-animations.enabled)

So the practical approach is progressive enhancement, not building on it directly. Branch with @supports.

@supports (animation-timeline: view()) {
  /* only in browsers that support it */
}

Build the baseline with the Intersection Observer API. The worst outcome is content stuck at opacity: 0 in an unsupported browser—avoid that above all.

Sponsored

scroll(): a reading-progress bar

The clearest example is a progress bar pinned to the top of the page. No JavaScript at all.

<div class="progress-bar" aria-hidden="true"></div>
.progress-bar {
  position: fixed;
  inset-block-start: 0;
  inset-inline: 0;
  height: 4px;
  background: #2a7fff;
  transform-origin: 0 50%;
  transform: scaleX(0);
  z-index: 100;
}

@supports (animation-timeline: scroll()) {
  .progress-bar {
    animation: grow-progress linear both;
    /* the root scroller, block axis */
    animation-timeline: scroll(root block);
  }
}

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

The arguments to scroll() say which scroller and which axis.

Syntax Meaning
scroll() Nearest ancestor scroller, block axis
scroll(root) The page scroller
scroll(root block) Page vertical scroll
scroll(nearest inline) Nearest scroller, horizontal
scroll(self block) The element’s own scroll

Animate transform: scaleX(), not width. Animating width forces layout on every scroll frame.

view(): reveal elements as they enter

This is the one you will reach for most. It uses the progress of an element entering and leaving the viewport.

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

@supports (animation-timeline: view()) {
  .card {
    animation: fade-in-up linear both;
    animation-timeline: view();
    /* finish 40% of the way through the entry phase */
    animation-range: entry 0% entry 40%;
  }
}

@keyframes fade-in-up {
  from { opacity: 0; transform: translateY(32px); }
  to   { opacity: 1; transform: none; }
}

animation-range is the critical part. Omit it and the whole span from “starting to appear” to “fully gone” is used, so the animation keeps advancing while the element sits in the middle of the screen. Limiting it to entry gives you a reveal.

Range Covers
entry From first appearing at the bottom until fully inside
exit From starting to leave at the top until fully gone
cover The entire span from appearing to disappearing
contain While the whole element fits inside the viewport
entry-crossing / exit-crossing While the element straddles a boundary

Notice that opacity: 0 is not in the base rule. Outside the @supports block, content renders normally—so it stays readable in Firefox.

Sponsored

Image zoom and parallax

view() with cover gives you a zoom that follows scroll position.

.hero-image {
  overflow: hidden;
}

@supports (animation-timeline: view()) {
  .hero-image img {
    animation: zoom-out linear both;
    animation-timeline: view();
    animation-range: cover 0% cover 100%;
  }
}

@keyframes zoom-out {
  from { transform: scale(1.25); }
  to   { transform: scale(1); }
}

Because scrolling back reverses the scale, you never get the drift that JavaScript parallax implementations accumulate.

Using a distant element as the timeline

Timelines are only looked up through ancestors by default. To reference a sibling or a distant element, name it and share it with timeline-scope.

/* the element that drives the timeline */
.article-body {
  view-timeline-name: --article;
  view-timeline-axis: block;
}

/* publish the name on a common ancestor */
.layout {
  timeline-scope: --article;
}

/* the element being animated, elsewhere in the tree */
.sidebar-indicator {
  animation: grow-progress linear both;
  animation-timeline: --article;
}

Forget timeline-scope and the animation silently does nothing—no error. When something will not animate, check this first.

Required accessibility work

prefers-reduced-motion

Motion tied directly to scrolling is particularly hard on people sensitive to vestibular triggers. This is not optional.

@media (prefers-reduced-motion: reduce) {
  .card,
  .hero-image img,
  .progress-bar {
    animation: none;
  }
  .progress-bar { display: none; }
}

What to animate

Restrict yourself to transform and opacity. Because values update on every scroll frame, animating width or top is even more expensive here than in a normal animation.

Hide decoration from assistive technology

Add aria-hidden="true" to purely decorative elements such as progress bars. There is nothing for a screen reader to announce.

When to use JavaScript instead

Goal Use
Motion that tracks scroll continuously CSS animation-timeline
A one-shot reveal Intersection Observer
Run something after it finishes Web Animations API
Load more content on scroll Infinite scroll with Intersection Observer

For a simple one-time fade-in, Intersection Observer is more reliable because it works everywhere. Scroll-driven animation earns its place when the display genuinely needs to stay locked to scroll position.

Summary

  • The @scroll-timeline at-rule was abandoned and does not work. Use the animation-timeline property
  • scroll() for container progress, view() for element reveals
  • With view(), set a range such as animation-range: entry 0% entry 40%
  • duration is ignored. Write animation: name linear both;
  • Firefox has it disabled by default. Layer it on with @supports (animation-timeline: view())
  • Keep the base state visible so content never disappears in unsupported browsers
  • For distant elements: view-timeline-name plus timeline-scope
  • Animate only transform and opacity, and always handle prefers-reduced-motion

“CSS-only scroll animation” is real, but it is not yet a technology that runs everywhere. Build the foundation in JavaScript and let supporting browsers upgrade.