Scroll-Driven Animations
Time-based @keyframes run on the clock. Scroll-driven animations run on scroll progress instead: scrubbing a
page or sliding an element through the viewport advances the same keyframes.
This guide goes deeper than the brief mention in
Transitions & Animations. You will learn animation-timeline
with scroll() and view(), how ranges work, and how to fall back when support is missing.
The problem they solve
Classic "animate on scroll" patterns wire IntersectionObserver (or a library) to toggle a class when an element
enters the viewport. That works, but it means:
- JavaScript for something that is often pure presentation
- Timing that is either on/off, not smoothly tied to scroll distance
- Extra work to keep progress in sync while the user scrolls up and down
Scroll-driven animations keep the motion in CSS. Scroll position is the timeline.
Core API
Link an animation to a timeline
Use the usual @keyframes and animation-name, then set animation-timeline instead of relying on time alone:
@keyframes reveal {
from {
opacity: 0;
transform: translateY(2rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
animation-duration still appears in the shorthand (often auto or a placeholder). Progress comes from the
timeline, not from wall-clock time. Prefer animation-timing-function: linear so keyframe percentages map
directly to scroll progress.
scroll() -- scroll progress of a scroller
scroll() creates an anonymous scroll progress timeline for a scroll container:
.progress-bar {
transform-origin: left;
animation: grow-x linear;
animation-timeline: scroll(root block);
}
@keyframes grow-x {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
| Argument | Meaning |
|---|---|
nearest (default) | Nearest ancestor scroll container |
root | Document (viewport) scroller |
self | The element itself, if it scrolls |
block / inline / x / y | Axis that drives progress (default block) |
Named timelines are useful when several elements share one scroller:
.scroller {
scroll-timeline-name: --page;
scroll-timeline-axis: block;
}
.progress {
animation-timeline: --page;
}
view() -- progress as an element crosses the scrollport
view() creates a view progress timeline based on the subject element's visibility inside its nearest scroller:
.feature {
animation: fade-slide linear both;
animation-timeline: view(block);
}
Optional insets shrink or expand when the element counts as "in view":
animation-timeline: view(block 10% 20%);
By default, progress runs from when the element first appears at one edge of the scrollport until it leaves the opposite edge. Insets let you start later or finish earlier.
animation-range
animation-range limits which portion of the timeline maps to your keyframes:
.card {
animation: reveal linear both;
animation-timeline: view();
/* Start when the element enters; finish once it covers 40% of the view */
animation-range: entry 0% cover 40%;
}
Common range names:
| Name | Rough meaning |
|---|---|
cover | From first visible until fully past the other edge |
contain | While the element is fully inside the scrollport |
entry / exit | Crossing in / crossing out |
entry-crossing / exit-crossing | Crossing the start / end edge |
Percentages after a name fine-tune the start and end of that phase. Check
MDN: animation-range for the full grammar --
browser implementations still catch up on every named range.
Patterns that work well
Reading progress indicator
Drive a thin top bar from root scroll:
.reading-progress {
position: fixed;
inset-block-start: 0;
inset-inline: 0;
block-size: 3px;
background: var(--color-primary);
transform-origin: left;
animation: grow-inline linear;
animation-timeline: scroll(root);
}
@keyframes grow-inline {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
Reveal cards as they enter
@media (prefers-reduced-motion: no-preference) {
.card {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
}
@keyframes reveal {
from {
opacity: 0;
transform: translateY(1.5rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
Parallax-ish layers (keep it subtle)
Tie background position or translate to scroll, but keep motion small and always respect reduced motion. Large parallax is a common accessibility failure.
Progressive enhancement vs IntersectionObserver
| Approach | Best for | Weakness |
|---|---|---|
| Scroll-driven CSS | Scrubbing, progress bars, continuous reveal tied to scroll distance | Support still catching up; harder to share logic with non-CSS systems |
| IntersectionObserver | One-shot enter/exit, analytics, lazy loading, complex branching | Not a smooth scrub timeline unless you write more JS |
| Libraries | Cross-browser polish, timeline tooling | Bundle cost; often unnecessary once native timelines land |
Practical rule:
- Ship a static or lightly transitioned default that works everywhere.
- Layer scroll-driven animations behind
@supportsor progressive enhancement when the motion is decorative. - Keep IntersectionObserver for behaviour (load data, pause video, fire analytics) -- not only for CSS class toggles.
.card {
/* Fallback: visible without animation */
opacity: 1;
}
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.card {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% cover 30%;
}
}
}
Accessibility
- Wrap decorative scroll motion in
prefers-reduced-motion: no-preference(or disable underreduce). - Do not encode required information only in a scroll animation.
- Prefer short ranges and modest transforms; long scrubbing parallax is often uncomfortable.
Gotchas
- Inactive timelines. If the chosen axis has no overflow / no scrollbar, progress stays at zero.
view()subject. The animated element is usually the subject whose visibility drives the timeline. Namedview-timelineon an ancestor is for sharing that progress with other elements.- Duration vs timeline. Do not expect wall-clock
animation-durationto drive progress when a scroll timeline is attached. - Fill mode.
animation-fill-mode: both(orforwards/backwardsas needed) keeps the start and end keyframes from flashing incorrectly at the edges of the range. - Support. Treat as progressive enhancement until your audience's browsers match your requirements -- verify on Can I use and MDN.
Browser support
Scroll-driven animations are available in Chromium for some time and have been landing in Safari and Firefox as Baseline coverage improves. Always check current tables before relying on them without a fallback.
Related reading
- Transitions & Animations -- time-based transitions and keyframes
- View Transitions API -- animating document or UI state changes, not scroll progress
- Scroll Snap & Carousels -- snapping and carousel patterns that pair with scroll
- MDN: Scroll-driven animations
- Inspiration: modern-css.com scroll-linked animations