Introduction to CSS Scroll Timeline
Modern web experiences demand more than static layouts. Users expect interfaces that respond fluidly to their interactions, and scrolling is one of the most fundamental gestures on the web. For years, developers relied on JavaScript and libraries like Intersection Observer or GSAP to create scroll-driven animations. Now, CSS Scroll Timeline — part of the scroll-driven animations specification — brings this capability natively to the browser, allowing you to tie animation progress directly to scroll position without writing a single line of JavaScript.
What Is CSS Scroll Timeline?
CSS Scroll Timeline is a new animation timeline type that replaces the traditional time-based timeline with one driven by scroll progression. Instead of an animation playing over seconds or milliseconds, it advances as the user scrolls through a scroll container. The specification defines two primary mechanisms:
- Scroll Progress Timeline (
scroll()): Links animation progress to the scroll position of a scroll container, either the nearest ancestor scroll container or a named one. - View Progress Timeline (
view()): Links animation progress to the relative position of a subject element as it crosses through a scrollport — perfect for reveal animations, parallax effects, and elements that animate as they enter or exit the viewport.
Both are specified via the animation-timeline CSS property, which accepts scroll(), view(), or a named timeline defined with the scroll-timeline property.
Why Scroll-Driven Animations Matter
The benefits of native scroll-driven animations go far beyond convenience:
- Performance: Because these animations run on the compositor thread, they remain smooth even when the main thread is busy. There's no need for
requestAnimationFrameloops or scroll event handlers that can cause jank. - Battery Efficiency: Native implementations are optimized by the browser engine, consuming less power than JavaScript-driven alternatives.
- Declarative Simplicity: You define what you want rather than how to achieve it. The browser handles the heavy lifting of interpolation, easing, and timeline management.
- Accessibility: Scroll-driven animations automatically respect user preferences like
prefers-reduced-motionwhen properly configured. - Smaller Bundles: Eliminating animation libraries reduces JavaScript payload size, leading to faster page loads.
Browser Support and Feature Detection
As of early 2025, scroll-driven animations are supported in Chrome 115+, Edge 115+, and Opera 101+. Firefox has the feature implemented behind a flag and is working toward full support. Safari has expressed positive signals and is actively working on implementation. You should always use feature detection:
/* Feature detection with @supports */
@supports (animation-timeline: scroll()) {
/* Your scroll-driven animation styles */
.animated-element {
animation: reveal linear;
animation-timeline: view();
}
}
/* Fallback for unsupported browsers */
@supports not (animation-timeline: scroll()) {
.animated-element {
opacity: 1; /* Immediately visible without animation */
}
}
For JavaScript-based detection, check the property on a computed style:
function supportsScrollTimeline() {
const el = document.createElement('div');
el.style.animationTimeline = 'scroll()';
return el.style.animationTimeline !== '';
}
if (supportsScrollTimeline()) {
// Enable scroll-driven features
}
Core Concepts: The Anatomy of a Scroll Timeline
To use scroll(), you need three essential pieces working together:
- A scroll container: An element with
overflow: auto,overflow: scroll, oroverflow-y: scrollthat generates a scrollable area. - A subject element: The element to which the animation is applied. This element must be inside the scroll container's subtree.
- A keyframe animation: Defined with
@keyframesas usual, but linked to scroll progress instead of time.
The scroll() function accepts two optional arguments:
/* Syntax */
animation-timeline: scroll([<scroller>] [<axis>]);
/* <scroller> values: nearest | root | self */
/* <axis> values: block | inline | vertical | horizontal */
nearest(default): Uses the nearest ancestor scroll container.root: Uses the document's scrolling element (the viewport).self: The element itself acts as the scroll container (useful for overflow: auto on the animated element itself).
The axis parameter defaults to block, which maps to vertical in horizontal writing modes and horizontal in vertical writing modes.
Anonymous vs. Named Scroll Timelines
When you use scroll() directly inside animation-timeline, you're creating an anonymous timeline. For sharing a single timeline across multiple animations, define a named timeline with the scroll-timeline shorthand:
/* Define a named timeline on the scroll container */
.scroll-container {
overflow-y: scroll;
scroll-timeline: --gallery-scroller block;
/* Shorthand: scroll-timeline-name + scroll-timeline-axis */
}
/* Multiple elements can reference the same timeline */
.card:nth-child(1) {
animation: fadeIn linear;
animation-timeline: --gallery-scroller;
}
.card:nth-child(2) {
animation: slideUp linear;
animation-timeline: --gallery-scroller;
}
Named timelines are powerful when you have a shared scroll container and want coordinated animations across multiple elements, all driven by the same scroll progress.
View Progress Timeline with view()
The view() function creates a timeline based on an element's visibility within a scrollport. It's ideal for effects where elements animate as they scroll into view. The timeline starts when the element begins to enter the scrollport and ends when it fully exits.
/* Syntax */
animation-timeline: view([<axis>] [<view-inset>]);
/* Examples */
animation-timeline: view(); /* default: block axis, auto insets */
animation-timeline: view(block 10px); /* 10px inset on start and end */
animation-timeline: view(block 20% 40%); /* different start and end insets */
animation-timeline: view(inline); /* horizontal axis */
The view-inset values adjust when the timeline begins and ends. A positive inset starts the animation later (the element must be further inside the viewport). A negative inset starts earlier (animation begins before the element fully enters). This gives you fine-grained control over animation timing:
/* Animation starts when the element's top is 100px inside the viewport
and ends when its bottom is 50px from the bottom edge */
.element {
animation: reveal linear;
animation-timeline: view(block 100px 50px);
}
View timelines also support timeline ranges via the animation-range property, which lets you target specific phases like entry, exit, contain, and cover:
/* Animate only during the entry phase */
.element {
animation: fadeIn linear;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
/* Animate during the exit phase */
.element {
animation: fadeOut linear;
animation-timeline: view();
animation-range: exit 0% exit 100%;
}
Practical Example: Scroll-Progress Bar
One of the most common use cases is a reading progress indicator. Here's a complete implementation:
<!-- HTML -->
<header class="reading-header">
<div class="progress-bar"></div>
</header>
<main class="article-content">
<!-- Long article content here -->
</main>
/* CSS */
.article-content {
/* This is the scroll container */
overflow-y: auto;
height: 100vh;
/* Define a named timeline for sharing */
scroll-timeline: --article-progress vertical;
}
.progress-bar {
transform-origin: left center;
/* Scale from 0 to 100% as the user scrolls */
animation: growBar linear;
animation-timeline: --article-progress;
height: 4px;
background: linear-gradient(to right, #6366f1, #a855f7);
width: 100%;
}
@keyframes growBar {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
The progress bar scales horizontally from 0% to 100% as the user scrolls through the article. No JavaScript, no event listeners — just declarative CSS.
Practical Example: Reveal-on-Scroll Cards
Using view(), you can create elegant entrance animations for a grid of cards:
<div class="card-grid">
<div class="card">Card 1</div>
<div class="card">Card 2</div>
<div class="card">Card 3</div>
<div class="card">Card 4</div>
<div class="card">Card 5</div>
<div class="card">Card 6</div>
</div>
/* CSS */
.card-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
padding: 40px;
/* The body or a wrapper is the scroll container */
}
.card {
padding: 40px;
background: #1e293b;
border-radius: 12px;
color: #f8fafc;
/* Each card animates independently as it enters the viewport */
animation: cardReveal linear;
animation-timeline: view();
animation-range: entry 0% entry 80%;
/* Start invisible and off-screen */
opacity: 0;
transform: translateY(40px);
}
@keyframes cardReveal {
from {
opacity: 0;
transform: translateY(40px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* Staggered effect via animation-delay doesn't work with scroll timelines.
Instead, use animation-range to create staggering */
.card:nth-child(1) { animation-range: entry 0% entry 80%; }
.card:nth-child(2) { animation-range: entry 10% entry 90%; }
.card:nth-child(3) { animation-range: entry 20% entry 100%; }
.card:nth-child(4) { animation-range: entry 30% entry 100%; }
.card:nth-child(5) { animation-range: entry 40% entry 100%; }
.card:nth-child(6) { animation-range: entry 50% entry 100%; }
Each card fades in and slides up as it scrolls into view. The staggered animation-range values create a cascading effect without any JavaScript.
Practical Example: Parallax Hero Section
Parallax effects are notoriously tricky to implement smoothly. With scroll timelines, they become straightforward:
<section class="hero">
<div class="hero-background"></div>
<div class="hero-content">
<h1>Welcome to the Future of CSS</h1>
<p>Scroll-driven animations are here.</p>
</div>
</section>
<section class="main-content">
<!-- Rest of the page -->
</section>
/* CSS */
.hero {
position: relative;
height: 100vh;
overflow: hidden;
}
.hero-background {
position: absolute;
inset: 0;
background: url('hero-image.jpg') center/cover;
/* Parallax: moves slower than scroll, creating depth */
animation: parallaxMove linear;
animation-timeline: scroll(root);
/* The background moves from -20% to 20% as user scrolls */
--parallax-start: -15%;
--parallax-end: 10%;
transform: translateY(var(--parallax-start));
}
.hero-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
/* Content fades out as user scrolls past the hero */
animation: fadeOutContent linear;
animation-timeline: scroll(root);
animation-range: exit 0% exit 100%;
}
@keyframes parallaxMove {
from {
transform: translateY(-15%);
}
to {
transform: translateY(10%);
}
}
@keyframes fadeOutContent {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-30px);
}
}
The background image moves at a different rate than the foreground content, creating a convincing parallax depth effect. The hero content gracefully fades out as the user scrolls into the main content area.
Practical Example: Horizontal Scroll Gallery with Snap Points
Combine CSS scroll snap with scroll timelines for an interactive gallery:
<div class="gallery-scroller">
<div class="gallery-track">
<div class="gallery-item">
<img src="photo1.jpg" alt="">
<span class="caption">Mountain Sunrise</span>
</div>
<div class="gallery-item">
<img src="photo2.jpg" alt="">
<span class="caption">Coastal Fog</span>
</div>
<div class="gallery-item">
<img src="photo3.jpg" alt="">
<span class="caption">Desert Bloom</span>
</div>
<div class="gallery-item">
<img src="photo4.jpg" alt="">
<span class="caption">Forest Canopy</span>
</div>
</div>
</div>
/* CSS */
.gallery-scroller {
overflow-x: scroll;
scroll-snap-type: x mandatory;
/* Define the timeline on the horizontal scroll axis */
scroll-timeline: --gallery-progress inline;
}
.gallery-track {
display: flex;
width: max-content;
}
.gallery-item {
width: 80vw;
flex-shrink: 0;
scroll-snap-align: center;
position: relative;
overflow: hidden;
}
.gallery-item img {
width: 100%;
height: 60vh;
object-fit: cover;
/* Images scale up as they come into snap position */
animation: scaleIn linear;
animation-timeline: view(inline);
animation-range: entry 0% entry 100%;
}
.caption {
position: absolute;
bottom: 20px;
left: 20px;
color: white;
font-size: 1.5rem;
/* Captions slide up from below */
animation: slideUpCaption linear;
animation-timeline: view(inline);
animation-range: entry 40% entry 100%;
opacity: 0;
transform: translateY(20px);
}
@keyframes scaleIn {
from {
transform: scale(0.85);
filter: brightness(0.6);
}
to {
transform: scale(1);
filter: brightness(1);
}
}
@keyframes slideUpCaption {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
This gallery uses view(inline) on each item so animations trigger as items scroll horizontally into the snap position. The images brighten and scale up, while captions slide in from below.
Advanced: Combining Multiple Timelines with animation-range
The animation-range property is where scroll-driven animations truly shine. It allows you to map specific portions of the scroll timeline to different phases of an animation. The property accepts named range phases:
entry: The element is entering the scrollport.exit: The element is leaving the scrollport.contain: The element is fully contained within the scrollport.cover: The element covers the entire scrollport (extends beyond both edges).
/* A complex reveal with multiple animation phases */
.card {
animation-name: scaleUp, fadeIn, glowPulse;
animation-timeline: view(), view(), view();
animation-range:
entry 0% entry 60%, /* scaleUp: early entry */
entry 20% entry 100%, /* fadeIn: later entry */
contain 0% contain 100%; /* glowPulse: while fully visible */
}
This creates a layered effect: the card scales up first, then fades in opacity, and once fully visible, a subtle glow pulses throughout the containment phase.
Best Practices for Scroll Timeline
- Prefer compositor-only properties: Animate
transform,opacity, andfilterwhen possible. These don't trigger layout or paint, keeping animations on the compositor thread. Avoid animating properties likewidth,height,margin, orpadding. - Respect user motion preferences: Always wrap scroll-driven animations in a
prefers-reduced-motionmedia query fallback:@media (prefers-reduced-motion: reduce) { .animated-element { animation: none; opacity: 1; transform: none; } } - Use named timelines for coordination: When multiple elements need to animate in sync with the same scroll container, define a named timeline once and reference it everywhere. This avoids redundant timeline calculations.
- Test on real devices: Desktop scroll behavior differs from mobile. Test scroll-driven animations across devices and browsers. Pay attention to momentum scrolling on iOS and Android, which can affect timeline progression.
- Keep animations short in scroll range: Animations that span the entire scroll range can feel sluggish. Use
animation-rangeto condense the active portion to a smaller scroll window for snappier feedback. - Provide fallback styles: Always define the final state of animated properties as the default style. If the animation doesn't run (unsupported browser, reduced motion), the element should still look acceptable:
.card { /* Fallback: fully visible */ opacity: 1; transform: translateY(0); /* Animation overrides these when supported */ } @supports (animation-timeline: view()) { .card { opacity: 0; transform: translateY(40px); animation: cardReveal linear; animation-timeline: view(); } } - Debug with CSS custom properties: During development, expose the scroll progress as a CSS variable to visualize timing:
.debug-indicator { --progress: 0; width: calc(var(--progress) * 100%); background: red; } /* Update --progress via a tiny JS snippet during development */ /* In production, rely on the native timeline */ - Mind the scroll container: The default
scroll(nearest)finds the nearest ancestor with overflow. If no ancestor qualifies, it falls back to the document scroller. Be explicit withscroll(root)or named timelines to avoid surprises.
Combining with JavaScript for Advanced Control
While scroll timelines eliminate most animation JavaScript, you may still want programmatic control for things like pausing, seeking, or logging. The Web Animations API exposes scroll-driven animations just like time-based ones:
// Access a CSS-declared scroll-driven animation
const element = document.querySelector('.card');
const animations = element.getAnimations();
animations.forEach(animation => {
// Check if it's scroll-driven
if (animation.timeline && animation.timeline instanceof ScrollTimeline) {
console.log('Scroll timeline detected');
console.log('Current progress:', animation.effect.getComputedTiming().progress);
// You can pause and play
animation.pause();
animation.play();
// Seek to a specific progress point (0 to 1)
animation.currentTime = animation.timeline.duration * 0.5;
}
});
// Create a scroll-driven animation entirely in JS
const scrollTimeline = new ScrollTimeline({
source: document.querySelector('.scroll-container'),
axis: 'block',
// Optional: define scroll offsets
scrollOffsets: [
{ target: document.querySelector('.start-marker'), edge: 'start' },
{ target: document.querySelector('.end-marker'), edge: 'end' }
]
});
const keyframes = [
{ opacity: 0, transform: 'translateY(50px)' },
{ opacity: 1, transform: 'translateY(0)' }
];
element.animate(keyframes, {
timeline: scrollTimeline,
fill: 'both',
rangeStart: 'entry 0%',
rangeEnd: 'entry 100%'
});
The JavaScript API is particularly useful for dynamic scenarios where scroll containers or elements are added after page load, or when you need to synchronize scroll-driven animations with other interactive systems like WebGL renderers or canvas updates.
Performance Considerations and Debugging
Scroll-driven animations run on the compositor thread, but certain property combinations can force layout recalculations. Use Chrome DevTools' Performance panel with the "Scrolling" category enabled to identify bottlenecks. The Rendering panel's "Layer borders" and "Paint flashing" tools help visualize which elements are being repainted unnecessarily.
Key performance rules:
- Keep animated elements on their own compositor layers by applying
will-change: transformorwill-change: opacitysparingly (only to elements that actually animate). - Avoid
animation-timelineon hundreds of elements simultaneously. Useview()timelines which only activate when elements approach the viewport. - Prefer
scroll(root)overscroll(nearest)when animating relative to the main page scroll — it's more predictable and performs better.
Conclusion
CSS Scroll Timeline represents a fundamental shift in how we approach scroll-based interactivity on the web. By moving scroll-driven animations from JavaScript to the browser's compositor, we gain smoother performance, simpler code, and better accessibility — all while reducing our dependency on third-party libraries. The scroll() and view() functions, combined with animation-range and named timelines, give developers a declarative, expressive toolkit for creating everything from reading progress bars to parallax heroes and staggered card reveals. As browser support continues to expand, scroll-driven animations are poised to become a cornerstone of modern web design. Start experimenting today, keep your fallbacks robust, and embrace the scroll.