CSS Scroll-Driven Animations: No JavaScript Required
Scroll-linked animation in CSS only, from progress bars to parallax, plus the jank that scaleX and a careless animation-range cause.
Scroll-linked effects have usually meant an IntersectionObserver or a scroll listener with a requestAnimationFrame guard. CSS scroll-driven animations move that into the stylesheet.
Two Types of Scroll Timelines
scroll()links progress to scroll position in a container.view()links progress to an element's visibility in a scroller.
You pick one per animation with animation-timeline.
Progress Bar With scroll()
A progress bar that fills as you scroll:
@keyframes grow {
from { width: 0%; }
to { width: 100%; }
}
.progress-bar {
position: fixed;
top: 0;
left: 0;
height: 4px;
background: oklch(60% 0.2 260);
animation: grow linear;
animation-timeline: scroll(root block);
animation-fill-mode: both;
}scroll(root block) is the document scroller on the vertical axis. Without both, the bar snaps to zero outside the range.
width triggers layout on every frame, so if the bar stutters, pin the element at full width and animate transform: scaleX() instead.
Reveal on Scroll With view()
Fade elements in as they enter the viewport:
@keyframes fade-in {
from {
opacity: 0;
translate: 0 40px;
}
to {
opacity: 1;
translate: 0 0;
}
}
.card {
animation: fade-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 40%;
}entry 0% entry 40% starts as the top edge enters and finishes once 40% of the element is visible. On a tall card that is a long scroll; I ended up nearer 20%.
animation-range Reference
It picks which portion of the timeline drives the animation:
| Keyword | Meaning |
|---|---|
entry | Element entering the scroller |
exit | Element leaving the scroller |
contain | While element is fully contained |
cover | From first pixel entering to last pixel leaving |
/* Play animation only while element is entering */
animation-range: entry 0% entry 100%;
/* Play while element covers the center of viewport */
animation-range: cover 25% cover 75%;cover is usually what people mean by "while it's on screen". contain counts only the stretch where it is fully inside the viewport.
Named Timelines for Complex Layouts
Use a named timeline when the scroller is not an ancestor:
.scroller {
overflow-y: scroll;
scroll-timeline-name: --my-scroll;
scroll-timeline-axis: block;
}
.indicator {
animation-timeline: --my-scroll;
}The name has to be a custom identifier starting with --, or the declaration is dropped.
Parallax Effect
@keyframes parallax-shift {
from { transform: translateY(0); }
to { transform: translateY(-100px); }
}
.hero-image {
animation: parallax-shift linear both;
animation-timeline: scroll(root);
animation-range: 0% 50%;
}Parallax from a scroll listener is where I have seen the worst jank.
Browser Support
Chrome 115+, Edge 115+, Safari 18+ (fall 2024) and Firefox 132. Wrap it in @supports (animation-timeline: scroll()): an ignored animation-timeline leaves a normal timed animation, so cards fade in on load instead of on scroll.
Progress bars and fade-ins are where this has stuck for me. Neither needs a library.