
Declarative Scroll Effects with CSS Scroll-Driven Animations
Historically, binding visual transitions to scroll position required imperative JavaScript listeners attached to window.addEventListener('scroll') or continuous calculations inside requestAnimationFrame. Even modern implementations utilizing IntersectionObserver often encounter main-thread synchronization bottlenecks, causing noticeable scroll lag, frame drops, and layout thrashing as the browser calculates DOM rects and styles in lockstep with user input gestures.
The CSS Scroll-Driven Animations specification fundamentally transforms this paradigm by decoupling scroll-progress tracking from the JavaScript main thread. By defining declarative timelines with scroll() or view(), the browser engine coordinates CSS keyframes directly on the compositor thread. Developers can map animations against the overall scroll offset of a scroller container or monitor a specific element traversing the viewport using timeline ranges such as entry, cover, contain, and exit, unlocking buttery smooth 60 and 120 FPS visual effects.
Example
/* Reading progress bar tracking root page scroll */
@keyframes indicateProgress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.reading-progress-indicator {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 4px;
background-color: var(--accent-color, #2563eb);
transform-origin: 0% 50%;
animation: indicateProgress linear;
animation-timeline: scroll(root block);
}
Implementation
<!-- Complete demo showing both page scroll progress and scroll-triggered image reveals -->
<div class="scroll-container">
<!-- Fixed reading progress bar at top of viewport -->
<div class="progress-bar-track">
<div class="progress-bar-fill"></div>
</div>
<article class="article-content">
<header class="hero-section">
<h1>Optimizing Visual Pacing with Scroll Timelines</h1>
<p class="lead">Scroll down to view compositor-driven entrance reveals.</p>
</header>
<section class="content-block">
<p>
Scroll-driven animations map the progression of a CSS animation directly to the
scroll offset of a scroll container rather than wall-clock time.
</p>
<!-- Card revealed declaratively as it enters the viewport -->
<figure class="reveal-card">
<img
src="/img/posts/css-scroll-driven-animations-view-timeline.webp"
alt="Scenic mountain landscape under sunlight"
loading="lazy"
width="667"
height="518"
/>
<figcaption>Compositor-driven fade and subtle upward parallax scale.</figcaption>
</figure>
<p>
Because computation executes inside the GPU compositor thread, heavy JavaScript tasks
executing on the main thread will never drop animation frames or stutter scroll playback.
</p>
<figure class="reveal-card">
<img
src="/img/posts/javascript-set-methods-union-intersection.webp"
alt="Modern architectural structure"
loading="lazy"
width="667"
height="518"
/>
<figcaption>Each element defines its own view-timeline range independently.</figcaption>
</figure>
</section>
</article>
</div>
<style>
/* 1. Global Reading Progress Bar using scroll() */
@keyframes growProgressBar {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
.progress-bar-track {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 5px;
background: rgba(0, 0, 0, 0.05);
z-index: 1000;
}
.progress-bar-fill {
width: 100%;
height: 100%;
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
transform-origin: left center;
transform: scaleX(0);
/* Bind keyframe timeline to the nearest or root vertical scroller */
animation: growProgressBar auto linear;
animation-timeline: scroll(root block);
}
/* 2. Scroll-triggered Reveal Animations using view() */
@keyframes revealFromBottom {
0% {
opacity: 0;
transform: translateY(60px) scale(0.94);
filter: blur(4px);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
filter: blur(0px);
}
}
.reveal-card {
margin: 3rem 0;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1);
background-color: var(--surface-1, #f8fafc);
border: 1px solid var(--border-color, #e2e8f0);
/* Establish a view timeline tracking when this element enters the viewport */
view-timeline-name: --card-reveal;
view-timeline-axis: block;
/* Bind keyframe to view timeline and constrain range from entry to cover */
animation: revealFromBottom linear both;
animation-timeline: --card-reveal;
animation-range: entry 10% cover 35%;
}
.reveal-card img {
display: block;
width: 100%;
height: auto;
aspect-ratio: 667 / 518;
object-fit: cover;
}
.reveal-card figcaption {
padding: 1rem 1.25rem;
font-size: 0.95rem;
color: var(--text-muted, #64748b);
border-top: 1px solid var(--border-color, #e2e8f0);
}
/* Fallback for browsers without Scroll-Driven Animations support */
@supports not (animation-timeline: scroll()) {
.progress-bar-fill {
display: none;
}
.reveal-card {
opacity: 1;
transform: none;
filter: none;
}
}
</style>
Main Point: CSS Scroll-Driven Animations shift scroll-linked interactions directly to the browser compositor thread, eliminating JavaScript event listeners and eliminating frame drops across high-fidelity user interfaces.
Credits
Photo by David Marcu on Unsplash