CSS Scroll-Driven Animations: Zero-JS Parallax Scrolling, Progress Bars, and Reveal Effects
Farewell to Scroll Event Listeners
Traditional scroll animations rely on JavaScript scroll event listeners:
// ❌ Traditional approach: JS listener + forced synchronous layout
window.addEventListener('scroll', () => {
const progress = window.scrollY / (document.body.scrollHeight - window.innerHeight);
progressBar.style.width = `${progress * 100}%`;
// Every scroll triggers a reflow!
});
CSS scroll-driven animations accomplish all this with pure CSS, running on the compositor thread without blocking the main thread:
/* ✅ Pure CSS: Reading progress bar */
.progress-bar {
width: 100%;
height: 3px;
background: blue;
transform-origin: left;
animation: progress linear;
animation-timeline: scroll();
}
@keyframes progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
scroll-timeline Syntax
Anonymous Scroll Timeline
/* Based on the nearest scrollable ancestor's scroll */
.animated {
animation-timeline: scroll();
}
/* Specify direction */
.animated-x {
animation-timeline: scroll(x); /* Horizontal scroll */
}
.animated-y {
animation-timeline: scroll(y); /* Vertical scroll */
}
Named Scroll Timeline
/* Declare timeline name on the scroll container */
.scroll-container {
scroll-timeline: --page-scroll y;
overflow-y: auto;
}
/* Reference on animated elements */
.animated-element {
animation: fade-in linear;
animation-timeline: --page-scroll;
}
view-timeline Syntax
view-timeline is based on the progress of an element entering/leaving the viewport:
.reveal-item {
view-timeline: --item-reveal;
animation: reveal linear both;
animation-timeline: --item-reveal;
}
@keyframes reveal {
from {
opacity: 0;
transform: translateY(50px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
view-timeline-range: Precise Animation Range Control
.reveal-item {
view-timeline: --item-reveal;
animation: reveal linear both;
animation-timeline: --item-reveal;
/* Play animation only during the first 30% of element entering the viewport */
animation-range: entry 0% entry 30%;
}
| range Value | Meaning |
|---|---|
entry |
Element begins entering the viewport |
exit |
Element begins leaving the viewport |
cover |
Element is fully within the viewport |
contain |
Element from fully entering to beginning to leave |
entry-crossing |
Element is crossing the viewport edge entering |
Example 1: Parallax Scrolling
.parallax-container {
overflow-y: auto;
scroll-timeline: --parallax y;
height: 100vh;
}
.parallax-bg {
animation: parallax-move linear;
animation-timeline: --parallax;
}
@keyframes parallax-move {
from { transform: translateY(0); }
to { transform: translateY(-200px); }
}
/* Different parallax speeds for different depths */
.parallax-layer-1 { animation: parallax-move-slow linear; animation-timeline: --parallax; }
.parallax-layer-2 { animation: parallax-move-medium linear; animation-timeline: --parallax; }
.parallax-layer-3 { animation: parallax-move-fast linear; animation-timeline: --parallax; }
@keyframes parallax-move-slow { to { transform: translateY(-50px); } }
@keyframes parallax-move-medium { to { transform: translateY(-150px); } }
@keyframes parallax-move-fast { to { transform: translateY(-300px); } }
Example 2: Reading Progress Bar
/* Reading progress indicator at the top of the page */
.reading-progress {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 4px;
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
transform-origin: left;
z-index: 1000;
animation: reading-progress linear;
animation-timeline: scroll(root y);
}
@keyframes reading-progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
Example 3: Scroll Reveal
/* Fade in from below */
.reveal-up {
view-timeline: --reveal;
animation: reveal-up linear both;
animation-timeline: --reveal;
animation-range: entry 0% entry 40%;
}
@keyframes reveal-up {
from {
opacity: 0;
transform: translateY(80px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* Slide in from the left */
.reveal-left {
view-timeline: --reveal;
animation: reveal-left linear both;
animation-timeline: --reveal;
animation-range: entry 0% entry 30%;
}
@keyframes reveal-left {
from {
opacity: 0;
transform: translateX(-60px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
Staggered Delay
/* Use animation-delay for staggered effects */
.stagger-item:nth-child(1) { animation-delay: 0ms; }
.stagger-item:nth-child(2) { animation-delay: 80ms; }
.stagger-item:nth-child(3) { animation-delay: 160ms; }
.stagger-item:nth-child(4) { animation-delay: 240ms; }
.stagger-item:nth-child(5) { animation-delay: 320ms; }
Example 4: Horizontal Scroll Area
.horizontal-scroll {
overflow-x: auto;
scroll-timeline: --hscroll x;
}
.card {
animation: card-appear linear both;
animation-timeline: --hscroll;
animation-range: entry 0% entry 20%;
}
@keyframes card-appear {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
Example 5: Sticky Header Shrink
.page {
scroll-timeline: --page y;
overflow-y: auto;
}
.sticky-header {
position: sticky;
top: 0;
animation: header-shrink linear both;
animation-timeline: --page;
animation-range: 0px 100px;
}
@keyframes header-shrink {
from {
padding: 24px;
font-size: 2rem;
}
to {
padding: 12px;
font-size: 1.25rem;
}
}
Example 6: Image Sprite Sequence Animation
.sprite-container {
scroll-timeline: --sprite y;
overflow-y: auto;
}
.sprite-image {
animation: sprite-steps steps(24) both;
animation-timeline: --sprite;
}
@keyframes sprite-steps {
from { background-position: 0 0; }
to { background-position: -2400px 0; } /* 24 frames × 100px width */
}
JS vs CSS Scroll Animation Comparison
| Dimension | JS (scroll event) | CSS scroll-timeline |
|---|---|---|
| Performance | Main thread, potential jank | Compositor thread, 60fps |
| Code size | 20-50 lines | 5-10 lines |
| Compatibility | All browsers | Chrome 115+, Firefox 125+ |
| Flexibility | Arbitrary logic | Linear/segmented animations |
| Debugging | console.log | DevTools Animations panel |
| Memory | Manual listener cleanup needed | Auto-managed |
Polyfill Strategy
// Feature detection
const supportsScrollTimeline = CSS.supports('animation-timeline', 'scroll()');
if (!supportsScrollTimeline) {
// Fallback to IntersectionObserver + CSS class toggling
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
entry.target.classList.toggle('in-view', entry.isIntersecting);
});
}, { threshold: 0.2 });
document.querySelectorAll('.reveal-item').forEach(el => {
observer.observe(el);
});
}
Browser Compatibility
| Browser | scroll-timeline | view-timeline | animation-range |
|---|---|---|---|
| Chrome 115+ | ✅ | ✅ | ✅ |
| Edge 115+ | ✅ | ✅ | ✅ |
| Firefox 125+ | ✅ | ✅ | ✅ |
| Safari 17.4+ | ❌ | ❌ | ❌ |
Safari is still implementing this feature. It's recommended to use feature detection + IntersectionObserver fallback.
Best Practices
- Prefer view-timeline: Most scroll reveal effects are simpler with
view-timelinethanscroll-timeline - Precise animation-range control: Avoid animations running when elements are not visible
- Pair with will-change: Add
will-change: transform, opacityto animated elements - Limit simultaneous animated elements: More than 20 simultaneously running scroll animations may impact performance
- Feature detection fallback: Use IntersectionObserver fallback when Safari doesn't support it
Summary
CSS scroll-driven animations are one of the most exciting new frontend features in 2026. They bring classic effects like parallax scrolling, reading progress bars, and scroll reveals back from JavaScript to CSS, with significant performance gains (running on the compositor thread, not blocking the main thread). While Safari support is still incomplete, combined with IntersectionObserver fallbacks, you can start using them in production today.
Try these browser-local tools — no sign-up required →