Understanding CSS Scroll-Driven Animations
Scroll-driven animations represent a paradigm shift in how we think about animation on the web. Instead of animations being tied to time—starting when a page loads or when a user clicks a button—they are now directly linked to the scroll position of a container. This creates an immediate, tactile connection between user input and visual feedback, transforming passive scrolling into an interactive storytelling experience.
The CSS specification introduces two primary mechanisms: Scroll Progress Timelines and View Progress Timelines. A scroll progress timeline maps an animation's progress to the fractional scroll position of a scroll container. A view progress timeline maps an animation's progress to the relative position of a subject element within a scroll container's viewport. Both are accessed through the new animation-timeline property and the scroll() and view() functional notations.
Why Scroll-Driven Animations Matter
Before these native CSS capabilities, developers relied on JavaScript—often using IntersectionObserver and manual scroll event handlers—to achieve parallax effects, sticky elements that transform, or fade-in-on-scroll behaviors. These approaches carried significant costs:
- Main-thread congestion: Scroll events fire rapidly on the main thread, forcing expensive calculations that compete with rendering and user input processing.
- Jank and stutter: JavaScript-driven scroll animations frequently suffer from missed frames because the main thread is busy, resulting in visually jarring experiences.
- Battery drain: Continuous polling of scroll position drains mobile device batteries far faster than compositor-only operations.
- Complexity: Maintaining smooth, 60fps scroll-linked animations required debouncing, throttling, requestAnimationFrame coordination, and careful state management.
Native scroll-driven animations run entirely on the compositor thread—the same thread responsible for scrolling and transforms. This means they can achieve buttery-smooth performance even during rapid scroll flings, without waking up the main thread at all. The browser can optimize the entire pipeline, from input to pixels, without JavaScript intervention.
The Core Building Blocks
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →The animation-timeline Property
The animation-timeline property is the gateway to scroll-driven animations. It accepts either the scroll() function, the view() function, or a named timeline defined via scroll-timeline or view-timeline CSS properties. When you set this property on an element alongside a standard @keyframes rule, the animation progress is driven by scroll position rather than elapsed time.
Scroll Progress Timeline with scroll()
The scroll() function creates an anonymous scroll progress timeline. It accepts two optional arguments: the scroll axis (defaulting to block) and the scroll container to track. The syntax is:
/* Track the nearest ancestor scroll container on the block axis */
animation-timeline: scroll();
/* Track the nearest ancestor scroll container on the inline axis */
animation-timeline: scroll(inline);
/* Track a specific named scroll container */
animation-timeline: scroll(block, .my-scroller);
/* Track the root scroller (the document) */
animation-timeline: scroll(block, root);
When using scroll(), the animation progresses from 0% to 100% as the scroll container moves from its scroll start (typically top) to its scroll end (typically bottom). The range is automatically calculated based on the scrollable overflow of the container.
View Progress Timeline with view()
The view() function creates a timeline based on an element's visibility within a scroll container. It accepts up to four arguments: the axis, the subject element (usually self), and the inset ranges for the start and end boundaries. This is incredibly powerful for "reveal on scroll" effects.
/* Animate based on the element's own visibility */
animation-timeline: view();
/* With custom inset ranges */
animation-timeline: view(block, self, 10px, 80%);
/* Equivalent longhand syntax using separate properties */
view-timeline-name: --my-reveal;
view-timeline-axis: block;
view-timeline-inset: 10px 80%;
The animation begins when the element's edge crosses the start inset boundary and completes when it reaches the end inset boundary. Insets behave like margins—positive values push the boundary inward from the viewport edges, while negative values extend them outward. This gives you precise control over exactly when an element begins and finishes its animation during the scroll journey.
Practical Implementation Guide
Basic Reveal-on-Scroll Effect
Let's build a classic "fade and slide up" reveal for content sections as the user scrolls down the page. This example uses a view progress timeline to trigger the animation naturally as each section enters the viewport.
<style>
.reveal-section {
/* Start invisible and shifted down */
opacity: 0;
transform: translateY(40px);
/* Attach the animation */
animation: reveal linear both;
animation-timeline: view();
/* Animation covers the range from when the element
first appears until it's 30% into the viewport */
animation-range: entry 0% entry 30%;
/* Ensure visibility before animation starts */
will-change: transform, opacity;
}
@keyframes reveal {
0% {
opacity: 0;
transform: translateY(40px) scale(0.95);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
</style>
<section class="reveal-section">
<h2>Discover Our Story</h2>
<p>This content gracefully reveals itself as you scroll down...</p>
</section>
<section class="reveal-section">
<h2>Our Philosophy</h2>
<p>Each section waits for its moment to shine...</p>
</section>
Notice the animation-range property: it allows you to clip the animation to a specific segment of the timeline. Here, entry 0% entry 30% means the animation runs from the moment the element's entry edge touches the viewport's entry edge (0% of the view timeline) until the element is 30% into the viewport. This creates a tight, impactful reveal window.
Parallax Header with Scroll Progress Timeline
Parallax effects are among the most popular use cases for scroll-driven animations. The following example creates a hero header that subtly scales and darkens as the user scrolls down the page, using the root scroll container as the timeline source.
<style>
.hero-header {
position: relative;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
/* Attach animation to the root document scroll */
animation: parallax-hero linear both;
animation-timeline: scroll(root, block);
/* Run from scroll 0% to scroll 30% of the page */
animation-range: 0% 30%;
}
.hero-header .hero-background {
position: absolute;
inset: 0;
background: url('hero-image.webp') center/cover no-repeat;
/* Counter-animation: moves slower, creating depth */
animation: bg-slow-zoom linear both;
animation-timeline: scroll(root, block);
animation-range: 0% 30%;
}
@keyframes parallax-hero {
0% {
/* Full vibrancy at top */
filter: brightness(1) blur(0px);
}
100% {
/* Muted and blurred as user scrolls past */
filter: brightness(0.6) blur(4px);
}
}
@keyframes bg-slow-zoom {
0% {
transform: scale(1);
}
100% {
/* Subtle zoom-in creates parallax depth */
transform: scale(1.15);
}
}
</style>
<header class="hero-header">
<div class="hero-background"></div>
<div class="hero-content">
<h1>Welcome to the Mountains</h1>
<p>Scroll to begin your journey</p>
</div>
</header>
This technique creates a gorgeous parallax depth effect without a single line of JavaScript. The animation-range: 0% 30% ensures the animation completes by the time the user has scrolled past 30% of the page height, preventing it from running indefinitely into content below.
Sticky Scroll-Spy Navigation
A common pattern is a navigation bar that transforms when it becomes sticky, or visual indicators that track scroll progress. Here's how to build a progress bar that fills as the user scrolls through an article:
<style>
.progress-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 4px;
background: #e0e0e0;
z-index: 1000;
}
.progress-bar .progress-fill {
height: 100%;
background: linear-gradient(to right, #6366f1, #ec4899);
/* Transform the width from 0% to 100% as user scrolls */
transform-origin: left center;
animation: fill-progress linear both;
animation-timeline: scroll(root, block);
/* Entire scroll range */
animation-range: 0% 100%;
}
@keyframes fill-progress {
0% {
transform: scaleX(0);
}
100% {
transform: scaleX(1);
}
}
</style>
<div class="progress-bar">
<div class="progress-fill"></div>
</div>
<article>
<!-- Long article content here -->
</article>
Using scaleX() instead of animating width is crucial—it keeps the animation compositor-friendly and avoids triggering layout recalculation on every frame.
Horizontal Scroll Gallery with Named Timelines
For more complex scenarios where multiple elements share the same scroll container, named timelines offer cleaner, more maintainable code. Here's a horizontally scrolling gallery where images rotate and fade based on their position within the scroller:
<style>
.gallery-scroller {
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
padding: 2rem 1rem;
/* Define the named timeline once */
scroll-timeline: --gallery-timeline inline;
/* The inline axis because we scroll horizontally */
}
.gallery-item {
display: inline-block;
width: 300px;
height: 400px;
margin-right: 1rem;
/* Each item animates based on its own visibility
within the scroller, using the named timeline */
animation: item-enter linear both;
animation-timeline: --gallery-timeline;
/* Animate only when the item enters/exits */
animation-range: entry 20% exit 80%;
}
@keyframes item-enter {
0% {
opacity: 0.3;
transform: rotateY(30deg) scale(0.8);
filter: grayscale(1);
}
50% {
opacity: 1;
transform: rotateY(0deg) scale(1);
filter: grayscale(0);
}
100% {
opacity: 0.3;
transform: rotateY(-30deg) scale(0.8);
filter: grayscale(1);
}
}
</style>
<div class="gallery-scroller">
<div class="gallery-item"><img src="photo1.webp" alt=""></div>
<div class="gallery-item"><img src="photo2.webp" alt=""></div>
<div class="gallery-item"><img src="photo3.webp" alt=""></div>
<div class="gallery-item"><img src="photo4.webp" alt=""></div>
<div class="gallery-item"><img src="photo5.webp" alt=""></div>
</div>
Named timelines shine when you have multiple animated elements responding to the same scroll container. Instead of repeating scroll(inline) on every element—which would create separate anonymous timelines—you define the timeline once and reference it by name. This ensures all animations stay perfectly synchronized.
Cover-Page Exit Animation
A dramatic effect for magazine-style layouts: a cover section that animates away as the user scrolls past it, revealing the content beneath:
<style>
.cover-section {
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #0f172a;
color: white;
/* Animate based on scroll progress of the root */
animation: cover-exit linear both;
animation-timeline: scroll(root, block);
/* Complete by the time user scrolls past this section */
animation-range: exit 0% exit 100%;
/* exit range: as the section exits the viewport */
}
@keyframes cover-exit {
0% {
opacity: 1;
transform: translateY(0);
clip-path: inset(0 0 0 0);
}
100% {
opacity: 0;
transform: translateY(-100vh);
clip-path: inset(100% 0 0 0);
}
}
</style>
<div class="cover-section">
<h1>Chapter One</h1>
<p>Scroll to continue reading...</p>
</div>
The exit range keyword is particularly useful here. It maps the animation to the period when the element is leaving the viewport, creating a natural transition from the cover to the content below.
Advanced Techniques and Tips
Combining Multiple Timelines
Elements can have multiple animations, each driven by different timelines. This enables sophisticated multi-axis effects:
<style>
.floating-element {
/* Vertical parallax driven by root scroll */
animation:
vertical-drift linear both,
horizontal-shift linear both;
animation-timeline:
scroll(root, block),
scroll(.horizontal-scroller, inline);
animation-range:
0% 100%,
0% 100%;
}
@keyframes vertical-drift {
0% { transform: translateY(-100px); }
100% { transform: translateY(100px); }
}
@keyframes horizontal-shift {
0% { transform: translateX(-50px); }
100% { transform: translateX(50px); }
}
</style>
Using animation-range for Precision
The animation-range property is perhaps the most powerful tool for fine-tuning scroll-driven animations. It accepts named range keywords (normal, entry, exit, contain, cover) or explicit percentage offsets:
/* Cover range: runs while element fully covers the viewport */
animation-range: cover 0% cover 100%;
/* Entry range: from when element starts entering until fully visible */
animation-range: entry 0% entry 100%;
/* Custom: start when element is 10% in, end at 90% */
animation-range: entry 10% exit 90%;
/* Absolute: from scroll position 200px to 800px */
animation-range: 200px 800px;
These range controls let you choreograph exactly when each animation fires during the scroll journey, creating layered, orchestrated reveals across multiple sections.
Performance-Conscious Property Selection
The golden rule of scroll-driven animations: only animate properties that the compositor can handle independently. The compositor thread can process transform, opacity, and filter without involving layout or paint. Animating properties like width, height, margin, padding, or background-position (in some cases) forces layout recalculation, defeating the performance benefits entirely.
/* Excellent: compositor-only properties */
@keyframes good-animation {
0% {
transform: translateY(50px) scale(0.9);
opacity: 0;
}
100% {
transform: translateY(0) scale(1);
opacity: 1;
}
}
/* Avoid: layout-triggering properties */
@keyframes bad-animation {
0% {
width: 0;
margin-left: 50px;
padding-top: 20px;
}
100% {
width: 100%;
margin-left: 0;
padding-top: 0;
}
}
If you must animate layout-affecting properties, consider using CSS containment (contain: layout) on the animated element's parent to isolate the layout impact, or use will-change to hint to the browser that the element will animate.
Accessibility Considerations
Scroll-driven animations can create accessibility challenges for users with vestibular disorders, those who rely on reduced motion, and keyboard-only users. Always respect the prefers-reduced-motion media query:
@media (prefers-reduced-motion: reduce) {
.reveal-section,
.hero-header,
.gallery-item {
/* Disable all scroll-driven animations */
animation: none;
/* Reset to visible state */
opacity: 1;
transform: none;
filter: none;
}
/* Fall back to simple fade for essential reveals */
.reveal-section {
transition: opacity 0.3s ease;
}
}
Additionally, ensure that content hidden by scroll-driven animations doesn't prevent keyboard navigation. Elements with opacity: 0 are still in the accessibility tree and focusable, but elements with display: none or visibility: hidden are not. Prefer opacity for hiding elements in scroll animations to maintain accessibility.
Best Practices Summary
- Start with
will-change: Applywill-change: transform, opacityon elements that will animate via scroll. This pre-allocates compositor layers and prevents frame-one repaints. - Use named timelines for shared scrollers: When multiple elements animate against the same scroll container, define a named timeline once and reference it. This ensures synchronization and reduces CSS redundancy.
- Constrain animation ranges: Use
animation-rangeaggressively. Letting animations run across the entire scroll range often creates subtle, barely-perceptible motion that feels sluggish. Tight ranges create punchy, satisfying reveals. - Test on real devices: Desktop scroll behavior differs significantly from mobile. Test on actual phones and tablets, paying attention to momentum scrolling, scroll snapping interactions, and viewport size variations.
- Provide fallbacks: Use
@supportsto detect scroll-driven animation support and provide alternative static styles or JavaScript-driven animations for browsers that don't yet support the feature. - Mind the scroll direction: Remember that
blockaxis typically means vertical scrolling in Latin-script layouts, whileinlinemeans horizontal. For RTL languages or vertical writing modes, these axes may differ. - Avoid over-animating: Too many simultaneous scroll-driven animations can overwhelm users and cause cognitive fatigue. Aim for one primary animation per scroll interaction, with secondary elements animating subtly if needed.
- Keep keyframe complexity low: Complex keyframes with many stops increase the compositor's workload. Stick to simple, two-keyframe animations when possible, and let the scroll timeline's natural easing do the heavy lifting.
Feature Detection and Progressive Enhancement
/* Detect support for scroll-driven animations */
@supports (animation-timeline: scroll()) {
.hero-header {
animation: parallax-hero linear both;
animation-timeline: scroll(root, block);
}
}
/* Fallback for unsupported browsers */
@supports not (animation-timeline: scroll()) {
.hero-header {
/* Static styling or JavaScript-driven fallback */
opacity: 1;
transform: none;
}
/* Optionally load a JavaScript polyfill */
.hero-header.js-scroll-animation {
/* JS handles the animation */
}
}
At the time of writing, scroll-driven animations are supported in Chrome 115+, Edge 115+, and Opera 101+, with active development underway in Firefox and Safari. For production use, progressive enhancement is essential—ensure your core content remains accessible and visually acceptable even without the animations.
Debugging Scroll-Driven Animations
Debugging scroll-driven animations requires different techniques than time-based animations. Chrome DevTools offers dedicated tooling: in the Animations panel, scroll-driven timelines appear with a scrollbar-style scrubber. You can manually drag through the animation range to inspect each frame. Additionally, the animation-range property can be temporarily widened during development to make subtle animations more obvious:
/* During development, widen the range for visibility */
.reveal-section {
/* Production range */
/* animation-range: entry 0% entry 30%; */
/* Debug range: runs across entire entry phase */
animation-range: entry 0% entry 100%;
}
You can also add a temporary visual indicator to show when animations are active by overlaying a debug element driven by the same timeline.
Conclusion
CSS scroll-driven animations represent one of the most significant advancements in declarative web animation. They bridge the gap between user intent and visual feedback in a way that feels immediate, natural, and deeply satisfying. By moving scroll-linked animations to the compositor thread, they deliver performance that JavaScript-based approaches simply cannot match. The key to mastering them lies in understanding the timeline types—scroll progress and view progress—choosing compositor-safe properties, respecting user accessibility preferences, and applying progressive enhancement for broad compatibility. Start experimenting with these techniques today, and you'll discover a new dimension of creative expression that transforms scrolling from a utilitarian action into an integral part of your storytelling.