Scroll-driven CSS animations
By Flavio Copes
Animate elements from scroll position with animation-timeline scroll() and view(). No JavaScript needed. Check current browser support before shipping.
Scroll-driven animations connect animation progress to a scroll position.
Instead of saying “run this animation for 500 milliseconds,” you say “run it from the top to the bottom of this scroller” or “run it while this element enters the viewport.”
The browser updates the animation as the user scrolls. You do not need a scroll event listener or repeated JavaScript calculations.
Scroll-driven animations build on normal CSS animations. The keyframes stay the same. We replace the time-based animation timeline with a scroll-based one.
The free CSS course covers keyframes, transforms, motion preferences, and responsive layout if you need those foundations first.
Two kinds of scroll timeline
CSS provides two useful timeline functions:
scroll()follows the scroll position of a containerview()follows an element as it moves through a scrollport
Use scroll() for reading progress bars, horizontal carousels, or effects tied to an entire page.
Use view() for a card that reveals as it enters the viewport, an image that changes while visible, or a section indicator.
These are scroll-driven animations. They are different from scroll-triggered animations that start at a certain point and then continue according to time.
Build a reading progress bar
Add one element near the start of the page:
<div class="reading-progress" aria-hidden="true"></div>
Position it at the top of the viewport:
.reading-progress {
position: fixed;
z-index: 10;
top: 0;
left: 0;
width: 100%;
height: 4px;
background: royalblue;
transform: scaleX(0);
transform-origin: left;
}
Now define the animation:
@keyframes grow-progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
Attach it to the root scroller:
.reading-progress {
animation: grow-progress 1ms linear;
animation-timeline: scroll(root block);
}
root means the document scroller. block means its block axis, which is vertical in a typical left-to-right page.
At the top, the timeline is at 0%. At the bottom, it reaches 100%. The scale animation follows that progress.
The 1ms duration does not make the scroll effect finish in one millisecond. Scroll position controls the progress. A small non-zero duration keeps the animation working in Firefox and provides a harmless fallback in browsers without scroll timelines.
Notice that animation-timeline comes after the animation shorthand. The shorthand resets the timeline to auto, so reversing those declarations would restore the normal time-based document timeline.
Animating transform is better than changing width on every frame. The browser can usually update a transform without recalculating the page layout.
Try it here. Scroll inside the frame and watch the blue bar:
Use the nearest scroll container
scroll() can follow a smaller scrolling element.
<section class="activity-panel">
<div class="panel-progress"></div>
<div class="activity-list">...</div>
</section>
Make the panel scroll:
.activity-panel {
position: relative;
max-height: 20rem;
overflow-y: auto;
}
Attach the animation to the nearest scroller:
.panel-progress {
position: sticky;
top: 0;
height: 3px;
background: seagreen;
transform-origin: left;
animation: grow-progress 1ms linear;
animation-timeline: scroll(nearest block);
}
nearest asks the browser to find the closest ancestor that scrolls. This keeps the component independent from the page.
Reveal an element with view()
A view timeline tracks one element as it passes through its nearest scrollport.
Define a small reveal:
@keyframes reveal-card {
from {
opacity: 0;
transform: translateY(2rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
Attach each card to its own view timeline:
.card {
animation: reveal-card 1ms linear both;
animation-timeline: view(block);
animation-range: entry 0% entry 100%;
}
view(block) watches the card on the block axis. The timeline starts before the card enters and ends after it leaves.
animation-range limits the part we use. Here the animation runs only during the entry range, from the first intersection until the card is fully inside the scrollport.
Scroll inside this frame. Each card reveals as it enters:
Understand the useful ranges
A view timeline contains named ranges. The most useful are:
entrywhile the element enterscontainwhile the element fits entirely insideexitwhile the element leavescoveracross the full visible journey
Reveal while entering, then fade while exiting:
@keyframes appear-and-leave {
entry 0% {
opacity: 0;
transform: scale(0.9);
}
entry 100%, exit 0% {
opacity: 1;
transform: scale(1);
}
exit 100% {
opacity: 0;
transform: scale(0.9);
}
}
.photo {
animation: appear-and-leave 1ms linear both;
animation-timeline: view();
}
Named range keyframes let the animation describe the element’s whole trip. For a first implementation, a simple animation-range is easier to debug.
Create a named timeline
Anonymous scroll() and view() timelines cover most cases. Use a named timeline when the scroller and animated element are not in a convenient ancestor relationship.
Suppose a horizontal gallery and its indicator are siblings:
<div class="gallery-shell">
<div class="gallery">...</div>
<div class="gallery-indicator"></div>
</div>
Name a scroll timeline on the container:
.gallery {
overflow-x: auto;
scroll-timeline-name: --gallery-scroll;
scroll-timeline-axis: inline;
}
Named timelines are normally available to descendants of the timeline source. The indicator is a sibling, so extend the timeline’s scope from their shared parent:
.gallery-shell {
timeline-scope: --gallery-scroll;
}
Now use that name from the indicator:
.gallery-indicator {
animation: grow-progress 1ms linear;
animation-timeline: --gallery-scroll;
}
Custom timeline names start with --, like custom properties.
If the animated element is already inside the scroller, you usually do not need timeline-scope. Start with an anonymous scroll() timeline and introduce a name only when the relationship requires it.
Drag the gallery sideways. The green indicator follows its scroll position:
Choose the correct axis
The logical axes adapt to writing mode:
blockis the direction blocks flow, usually verticalinlineis the direction text flows, usually horizontal
The physical axes do not adapt:
yfollows vertical scrollingxfollows horizontal scrolling
For a typical article progress bar, use block:
animation-timeline: scroll(root block);
For a horizontal gallery, use inline:
animation-timeline: scroll(nearest inline);
I prefer logical axes for components. They keep working when the document writing mode changes.
Adjust when a view animation starts
view() can inset the scrollport used by the timeline.
.card {
animation: reveal-card 1ms linear both;
animation-timeline: view(block 15% 15%);
}
The two 15% values move the effective start and end edges inward. The animation begins after the card crosses the inset edge and finishes before it reaches the other one.
You can also keep the default view timeline and select a range:
.card {
animation: reveal-card 1ms linear both;
animation-timeline: view();
animation-range: entry 20% cover 40%;
}
This starts partway through entry and finishes early in the full cover journey.
Use one technique at first. Combining insets, named ranges, and range keyframes too early makes the effect harder to debug.
Progressive enhancement
The content must remain usable without the animation.
Set the readable state first:
.card {
opacity: 1;
transform: none;
}
Add the scroll-driven behavior inside @supports:
@supports (animation-timeline: view()) {
.card {
animation: reveal-card 1ms linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
}
Unsupported browsers ignore the block. The cards remain visible.
Do not set opacity: 0 outside the feature query. Otherwise an unsupported browser can hide the content permanently.
Debug the timeline before the effect
When an animation does nothing, simplify it to two obvious states:
@keyframes debug-progress {
from {
background: red;
transform: scale(0.5);
}
to {
background: lime;
transform: scale(1);
}
}
Attach that animation to the same timeline. If it still does not move, check the timeline rather than the final design.
Work through these questions:
- Does the chosen element actually scroll?
- Did
animation-timelinecome after theanimationshorthand? - Is the axis correct?
- Can the animated element see the named timeline?
- Does the view subject travel far enough through the scrollport?
Browser developer tools can inspect CSS animations and grid the current computed properties. Check whether animation-timeline resolved to your scroll or view timeline instead of auto.
Respect reduced motion
Scroll-linked movement can be uncomfortable even though the user controls the scroll position.
Disable it when the user asks for reduced motion:
@media (prefers-reduced-motion: reduce) {
.reading-progress,
.card,
.photo {
animation: none;
}
}
A progress bar that only changes scale may be acceptable in some designs, but large parallax and reveal movements should disappear.
Common mistakes
The first mistake is putting the shorthand after the timeline:
.card {
animation-timeline: view();
animation: reveal-card 1ms linear both;
}
The animation line resets the timeline. Put animation-timeline last.
The second mistake is selecting an element that does not scroll. scroll(nearest) can resolve to a scroll container whose content has no overflow, leaving the timeline with no useful progress.
The third is using view() for an element that barely moves through the scrollport. A tall element can make ranges such as contain very short or impossible because it never fits fully inside.
The fourth is animating layout-heavy properties. Prefer transform and opacity for frequent visual updates. Test the effect on a slower device when it paints large shadows, filters, or backgrounds.
The fifth is hiding content in the initial state without a fallback. Progressive enhancement must start from readable content.
When CSS is enough
Use CSS when scroll position directly controls a visual property. Progress bars, reveals, scale changes, and subtle parallax are good fits.
Use JavaScript when scrolling must cause application work: load data, update the URL, start media, or send analytics. An animation timeline does not replace application logic.
Scroll-driven animations work in current browsers, but not every part has identical support. Check the official MDN scroll-driven animations guide for the current details and keep the fallback visible.
If you already know CSS animations and CSS transitions, the new part is only the timeline. Start with a reading progress bar, then try a single view() reveal.