Scroll-driven animations create engaging experiences where elements animate based on scroll position rather than time. A progress bar that fills as you read, elements that fade in as they enter the viewport, or parallax effects—all without JavaScript. The CSS Scroll-Driven Animations specification makes this native to browsers.
How scroll-driven animations work
Traditional CSS animations run on a timeline measured in seconds. Scroll-driven animations replace time with scroll progress:
| Timeline type | Progress measured by |
|---|---|
| Time-based | Seconds (default) |
| Scroll Timeline | Scroll position of a scroller |
| View Timeline | Element's visibility in viewport |
/* Time-based animation */
.element {
animation: fadeIn 1s ease-out;
}
/* Scroll-driven animation */
.element {
animation: fadeIn linear;
animation-timeline: scroll();
}
Browser support
| Browser | Support |
|---|---|
| Chrome 115+ | Full |
| Edge 115+ | Full |
| Firefox 110+ | Partial (flags) |
| Safari | In development |
Use feature detection:
@supports (animation-timeline: scroll()) {
.element {
animation: fadeIn linear;
animation-timeline: scroll();
}
}
Scroll Timeline
A Scroll Timeline tracks the scroll position of a container:
@keyframes progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.progress-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 4px;
background: blue;
transform-origin: left;
animation: progress linear;
animation-timeline: scroll();
}
As the page scrolls, the progress bar grows from 0 to full width.
Scroll Timeline options
.element {
/* Track scroll of nearest scrollable ancestor */
animation-timeline: scroll();
/* Track root scroller (usually document) */
animation-timeline: scroll(root);
/* Track specific axis */
animation-timeline: scroll(root block); /* Vertical */
animation-timeline: scroll(root inline); /* Horizontal */
}
Named scroll timelines
For more control, name your timeline:
.scroller {
overflow-y: scroll;
scroll-timeline-name: --my-scroller;
scroll-timeline-axis: block;
}
.animated-element {
animation: slideIn linear;
animation-timeline: --my-scroller;
}
View Timeline
A View Timeline tracks when an element enters and exits the viewport:
@keyframes fadeIn {
from { opacity: 0; transform: translateY(50px); }
to { opacity: 1; transform: translateY(0); }
}
.card {
animation: fadeIn linear both;
animation-timeline: view();
}
The card fades in as it scrolls into view.
View Timeline ranges
Control when the animation runs:
.card {
animation: fadeIn linear both;
animation-timeline: view();
/* Start when entering, end when fully visible */
animation-range: entry 0% cover 100%;
}
| Range keyword | Meaning |
|---|---|
cover |
From first pixel entering to last pixel leaving |
contain |
From fully inside to starts leaving |
entry |
Element entering viewport |
exit |
Element exiting viewport |
entry-crossing |
Element crossing entry edge |
exit-crossing |
Element crossing exit edge |
Percentage offsets
Fine-tune animation timing:
.card {
animation: fadeIn linear both;
animation-timeline: view();
/* Fade in during first 50% of visibility */
animation-range: entry 0% entry 100%;
}
Common patterns
Reveal on scroll
@keyframes reveal {
from {
opacity: 0;
transform: translateY(100px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.reveal {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
Parallax effect
@keyframes parallax {
from { transform: translateY(-50px); }
to { transform: translateY(50px); }
}
.parallax-layer {
animation: parallax linear;
animation-timeline: scroll();
}
Horizontal scroll gallery
.gallery {
overflow-x: scroll;
scroll-timeline-name: --gallery;
scroll-timeline-axis: inline;
}
.gallery-item {
animation: scaleUp linear both;
animation-timeline: view(inline);
}
@keyframes scaleUp {
0%, 100% { transform: scale(0.8); opacity: 0.5; }
50% { transform: scale(1); opacity: 1; }
}
Reading progress indicator
.progress {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(to right, #3b82f6, #8b5cf6);
transform-origin: left;
animation: progress linear;
animation-timeline: scroll(root);
}
@keyframes progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
JavaScript fallback
For browsers without support, use Intersection Observer:
if (!CSS.supports('animation-timeline', 'scroll()')) {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
}
});
}, { threshold: 0.1 });
document.querySelectorAll('.reveal').forEach((el) => {
observer.observe(el);
});
}
/* Fallback styles */
.reveal {
opacity: 0;
transform: translateY(100px);
transition: opacity 0.5s, transform 0.5s;
}
.reveal.visible {
opacity: 1;
transform: translateY(0);
}
/* Enhanced styles when supported */
@supports (animation-timeline: scroll()) {
.reveal {
opacity: 1;
transform: none;
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
}
Accessibility
Respect reduced motion preferences:
@media (prefers-reduced-motion: reduce) {
.reveal {
animation: none;
opacity: 1;
transform: none;
}
.progress {
animation: none;
transform: scaleX(1);
}
}
Performance benefits
| Approach | Performance |
|---|---|
| JavaScript scroll handler | Main thread, can cause jank |
| requestAnimationFrame | Better, still main thread |
| CSS scroll-driven | Off main thread, compositor |
CSS scroll-driven animations run on the compositor thread and don't cause layout recalculations. They're significantly smoother than JavaScript alternatives.
Debugging
Chrome DevTools supports scroll-driven animations:
- Open Elements panel
- Select animated element
- Check Animations panel for timeline visualization
- Use Performance panel to verify compositor usage
Summary
CSS scroll-driven animations tie animation progress to scroll position. Use scroll() for scroll containers and view() for viewport visibility. Control timing with animation-range. The API runs off the main thread for better performance than JavaScript. Provide fallbacks with Intersection Observer for unsupported browsers and respect reduced motion preferences.
