Scroll-Driven Animations
CSS Scroll-Driven Animations API provides performant, declarative scroll-linked animations without JavaScript. Supported in Chrome 115+, Edge 115+, Safari 18.4+.
Overview
-
Progress indicators tied to scroll position
-
Parallax effects without JavaScript jank
-
Element reveal animations on scroll into view
-
Sticky header animations based on scroll
-
Reading progress bars
-
Scroll-triggered image/content reveals
Core Concepts
Timeline Types
Timeline CSS Function Use Case
Scroll Progress scroll()
Tied to scroll container position (0-100%)
View Progress view()
Tied to element visibility in viewport
CSS Patterns
- Scroll Progress Timeline (Reading Progress)
/* Progress bar that fills as page scrolls */ .progress-bar { position: fixed; top: 0; left: 0; height: 4px; background: var(--color-primary); transform-origin: left;
/* Animate based on root scroll */ animation: grow-progress linear; animation-timeline: scroll(root block); }
@keyframes grow-progress { from { transform: scaleX(0); } to { transform: scaleX(1); } }
- View Timeline (Reveal on Scroll)
/* Fade in when element enters viewport */ .reveal-on-scroll { animation: fade-slide-up linear both; animation-timeline: view(); animation-range: entry 0% entry 100%; }
@keyframes fade-slide-up { from { opacity: 0; transform: translateY(50px); } to { opacity: 1; transform: translateY(0); } }
- Animation Range Control
/* Fine-tune when animation runs */ .card { animation: scale-up linear both; animation-timeline: view();
/* Start at 25% entry, complete at 75% entry */ animation-range: entry 25% entry 75%; }
/* Full visibility animation */ .hero-image { animation: parallax linear both; animation-timeline: view();
/* Animate through entire visibility */ animation-range: cover 0% cover 100%; }
@keyframes parallax { from { transform: translateY(-20%); } to { transform: translateY(20%); } }
- Named Scroll Timelines
/* Define timeline on scroll container */ .scroll-container { overflow-y: auto; scroll-timeline-name: --container-scroll; scroll-timeline-axis: block; }
/* Use timeline in descendant */ .progress-indicator { animation: progress linear; animation-timeline: --container-scroll; }
@keyframes progress { from { width: 0%; } to { width: 100%; } }
- Named View Timelines with Scope
/* Parent sets up the timeline scope */ .gallery { timeline-scope: --card-timeline; }
/* Each card defines its view timeline */ .gallery-card { view-timeline-name: --card-timeline; view-timeline-axis: block; }
/* Animate based on card visibility */ .gallery-card .image { animation: zoom-in linear both; animation-timeline: --card-timeline; animation-range: entry 0% cover 50%; }
@keyframes zoom-in { from { transform: scale(0.8); opacity: 0; } to { transform: scale(1); opacity: 1; } }
- Parallax Sections
.parallax-section { position: relative; overflow: hidden; }
.parallax-bg { position: absolute; inset: -20% 0;
animation: parallax-scroll linear both; animation-timeline: view(); animation-range: cover 0% cover 100%; }
@keyframes parallax-scroll { from { transform: translateY(0); } to { transform: translateY(40%); } }
- Sticky Header Animation
.header { position: sticky; top: 0;
animation: shrink-header linear both; animation-timeline: scroll(root); animation-range: 0px 200px; }
@keyframes shrink-header { from { padding-block: 2rem; background: transparent; } to { padding-block: 0.5rem; background: var(--color-surface); box-shadow: var(--shadow-md); } }
JavaScript API
ScrollTimeline
// Create scroll timeline programmatically const scrollTimeline = new ScrollTimeline({ source: document.documentElement, // or specific scroll container axis: 'block', // 'block' | 'inline' | 'x' | 'y' });
// Attach to animation element.animate( [ { transform: 'translateY(100px)', opacity: 0 }, { transform: 'translateY(0)', opacity: 1 }, ], { timeline: scrollTimeline, fill: 'both', } );
ViewTimeline
// Create view timeline for specific element const viewTimeline = new ViewTimeline({ subject: element, // Element to track axis: 'block', inset: [CSS.px(0), CSS.px(0)], // Optional viewport inset });
// Animate based on element visibility element.animate( [ { opacity: 0, transform: 'scale(0.8)' }, { opacity: 1, transform: 'scale(1)' }, ], { timeline: viewTimeline, fill: 'both', rangeStart: 'entry 0%', rangeEnd: 'cover 50%', } );
React Integration
import { useRef, useEffect } from 'react';
function useScrollAnimation( keyframes: Keyframe[], options: { timeline?: 'scroll' | 'view'; range?: string; } = {} ) { const ref = useRef<HTMLDivElement>(null);
useEffect(() => { const element = ref.current; if (!element || !('animate' in element)) return;
// Feature detection
if (!('ScrollTimeline' in window)) {
console.warn('Scroll-driven animations not supported');
return;
}
const timeline = options.timeline === 'view'
? new ViewTimeline({ subject: element, axis: 'block' })
: new ScrollTimeline({ source: document.documentElement, axis: 'block' });
const animation = element.animate(keyframes, {
timeline,
fill: 'both',
...(options.range && {
rangeStart: options.range.split(' ')[0],
rangeEnd: options.range.split(' ')[1],
}),
});
return () => animation.cancel();
}, [keyframes, options.timeline, options.range]);
return ref; }
// Usage function RevealCard({ children }: { children: React.ReactNode }) { const ref = useScrollAnimation( [ { opacity: 0, transform: 'translateY(50px)' }, { opacity: 1, transform: 'translateY(0)' }, ], { timeline: 'view', range: 'entry cover' } );
return <div ref={ref}>{children}</div>; }
Progressive Enhancement
/* Fallback for unsupported browsers / .reveal-on-scroll { opacity: 1; / Default visible */ transform: translateY(0); }
/* Apply animation only when supported */ @supports (animation-timeline: view()) { .reveal-on-scroll { animation: fade-slide-up linear both; animation-timeline: view(); animation-range: entry 0% entry 100%; } }
// Feature detection in React const supportsScrollTimeline = typeof ScrollTimeline !== 'undefined';
function AnimatedSection({ children }: { children: React.ReactNode }) { if (!supportsScrollTimeline) { // Fallback: use Intersection Observer return <IntersectionObserverFallback>{children}</IntersectionObserverFallback>; }
return <ScrollAnimatedSection>{children}</ScrollAnimatedSection>; }
Chrome DevTools Debugging
-
Open DevTools → Elements tab
-
Find "Scroll-Driven Animations" tab (may be in overflow ››)
-
Select element with scroll animation
-
Scrub timeline to preview animation
-
Inspect animation-timeline and animation-range values
Performance Best Practices
/* ✅ CORRECT: Animate transform/opacity only */ @keyframes good-animation { from { transform: translateY(100px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
/* ❌ WRONG: Animate layout properties */ @keyframes bad-animation { from { margin-top: 100px; height: 0; } to { margin-top: 0; height: auto; } }
/* ✅ Use will-change sparingly */ .scroll-animated { will-change: transform, opacity; }
Anti-Patterns (FORBIDDEN)
/* ❌ NEVER: Animate layout-triggering properties */ @keyframes bad { from { width: 0; margin-left: 100px; } to { width: 100%; margin-left: 0; } }
/* ❌ NEVER: Use without fallback / .element { animation-timeline: scroll(); / Breaks in Firefox! */ }
/* ❌ NEVER: Overly complex animation chains */ .element { animation: anim1, anim2, anim3, anim4, anim5; animation-timeline: view(), scroll(), view(), scroll(), view(); }
/* ❌ NEVER: Scroll animations on non-scrollable containers / .no-overflow { overflow: hidden; scroll-timeline-name: --timeline; / Won't work! */ }
Browser Support
Browser scroll() view() ScrollTimeline API
Chrome 115+ ✅ ✅ ✅
Edge 115+ ✅ ✅ ✅
Safari 18.4+ ✅ ✅ ✅
Firefox ❌ ❌ ❌ (in development)
Key Decisions
Decision Option A Option B Recommendation
Timeline type scroll() view() view() for reveals, scroll() for progress
Fallback strategy IntersectionObserver No animation IntersectionObserver fallback
Animation properties All CSS transform/opacity transform/opacity only
Range units Percentages Named ranges Named ranges (entry, cover) for clarity
Related Skills
-
motion-animation-patterns
-
Framer Motion for JS animations
-
core-web-vitals
-
Performance impact considerations
-
view-transitions
-
Complementary page transitions
Capability Details
scroll-timeline
Keywords: scroll(), progress, scroll position, reading progress Solves: Animations tied to scroll container position
view-timeline
Keywords: view(), visibility, reveal, enter viewport Solves: Animations triggered by element visibility
parallax-effects
Keywords: parallax, background, depth, scroll speed Solves: Performant parallax without JavaScript
scroll-triggered
Keywords: trigger, intersection, enter, exit, reveal Solves: Trigger animations on scroll position
progressive-enhancement
Keywords: fallback, @supports, feature detection Solves: Support for browsers without scroll-driven animations
References
-
references/css-scroll-timeline.md
-
CSS scroll() and view() functions
-
references/js-api.md
-
JavaScript ScrollTimeline/ViewTimeline API
-
scripts/parallax-section.tsx
-
React parallax component