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-timelineproperties together withanimation-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 barsview(): 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-timelineat-rule was abandoned and does not work. Use theanimation-timelineproperty scroll()for container progress,view()for element reveals- With
view(), set a range such asanimation-range: entry 0% entry 40% durationis ignored. Writeanimation: 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-nameplustimeline-scope - Animate only
transformandopacity, and always handleprefers-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.