CSS Scroll-Driven Animations
Master scroll-driven animations with native CSS. Create parallax effects, progress indicators, and scroll-triggered animations without JavaScript. Control animation progress based on scroll position using animation-timeline, scroll(), and view().
What Are Scroll-Driven Animations?
Scroll-driven animations let you control CSS animations based on scroll position instead of time. The animation progress is tied to how far a user has scrolled, creating interactive and performant scroll effects.
📜 Scroll Timeline
Drive animations based on the scroll position of a scrollable container
👁️ View Timeline
Trigger animations when elements enter or exit the viewport
⚡ Performant
Runs on the compositor thread for smooth 60fps animations
🎯 No JavaScript
Pure CSS solution, no scroll event listeners needed
Why use scroll-driven animations?
- ✅ Better performance than JavaScript scroll listeners
- ✅ Declarative, easier to maintain
- ✅ Runs on compositor thread (smooth animations)
- ✅ Built-in support for viewport-based triggers
- ✅ No layout thrashing or reflows
animation-timeline Property
The animation-timeline property connects an animation to a scroll timeline instead of the default time-based timeline.
Basic Syntax
/* Link animation to scroll position */
.element {
animation: fadeIn linear;
animation-timeline: scroll();
}
/* Animation progresses as you scroll */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(50px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
Key Concept: Instead of animation-duration, the scroll position determines animation progress. 0% scroll = 0% animation, 100% scroll = 100% animation.
Timeline Values
/* Scroll-based timeline */
.element {
animation-timeline: scroll(); /* Nearest scrollable ancestor */
animation-timeline: scroll(root); /* Document root scroller */
animation-timeline: scroll(nearest); /* Nearest ancestor (default) */
animation-timeline: scroll(self); /* Element itself */
}
/* View-based timeline */
.element {
animation-timeline: view(); /* When element enters/exits view */
}
/* Named timeline */
.element {
animation-timeline: --my-scroller; /* Reference named timeline */
}
/* Default time-based */
.element {
animation-timeline: auto; /* Normal time-based animation */
}
scroll() Function
The scroll() function creates a scroll timeline based on a scroll container's scroll position.
Syntax
animation-timeline: scroll( );
/* Scroller values */
scroll() /* nearest scrollable ancestor */
scroll(root) /* document root */
scroll(nearest) /* nearest ancestor (default) */
scroll(self) /* element itself */
/* Axis values */
scroll(block) /* block axis (default, vertical in LTR) */
scroll(inline) /* inline axis (horizontal in LTR) */
scroll(y) /* always vertical */
scroll(x) /* always horizontal */
Examples
/* Vertical scroll on nearest ancestor */
.element {
animation: slide linear;
animation-timeline: scroll(nearest block);
}
/* Horizontal scroll on root */
.element {
animation: fade linear;
animation-timeline: scroll(root x);
}
/* Element's own scroll */
.scrollable-element {
animation: rotate linear;
animation-timeline: scroll(self y);
overflow-y: auto;
}
@keyframes slide {
from { transform: translateX(-100px); }
to { transform: translateX(100px); }
}
@keyframes fade {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
Scroll Timeline Example: Progress Bar
/* Reading progress indicator */
.progress-bar {
position: fixed;
top: 0;
left: 0;
height: 4px;
background: linear-gradient(to right, #667eea, #764ba2);
transform-origin: left;
animation: grow-progress linear;
animation-timeline: scroll(root block);
}
@keyframes grow-progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
🔄 Interactive Demo: Rotation (Spins as You Scroll)
view() Function
The view() function creates a timeline based on when an element enters and exits the viewport. Perfect for scroll-triggered reveal animations.
Syntax
animation-timeline: view( );
/* Axis values */
view() /* block axis (default) */
view(block) /* vertical in LTR */
view(inline) /* horizontal in LTR */
view(y) /* always vertical */
view(x) /* always horizontal */
/* Inset (optional) - adjusts viewport boundaries */
view(auto) /* no inset */
view(20px) /* 20px inset on all sides */
view(10% 20%) /* 10% top/bottom, 20% left/right */
How View Timeline Works
Timeline Progress:
- 0% - Element enters viewport (bottom edge visible)
- 50% - Element is centered in viewport
- 100% - Element exits viewport (top edge leaves)
View Timeline Examples
/* Fade in as element enters viewport */
.fade-in {
animation: fadeIn linear;
animation-timeline: view();
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(50px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Slide from left when entering */
.slide-in {
animation: slideFromLeft linear;
animation-timeline: view();
}
@keyframes slideFromLeft {
from {
transform: translateX(-100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Scale up when centered */
.scale-in {
animation: scaleUp ease-out;
animation-timeline: view();
}
@keyframes scaleUp {
0% {
transform: scale(0.5);
opacity: 0;
}
50% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 1;
}
}
View Timeline with Inset
/* Start animation earlier (100px before entering viewport) */
.early-start {
animation: fadeIn linear;
animation-timeline: view(-100px);
}
/* Start when 20% into viewport */
.delayed-start {
animation: fadeIn linear;
animation-timeline: view(20% 0%);
}
/* Custom viewport boundaries */
.custom-bounds {
animation: slideIn linear;
animation-timeline: view(10% 10%); /* Smaller effective viewport */
}
✨ Interactive Demo: Fade In on Entry (view timeline)
🎯 Interactive Demo: Scale & Rotate on Entry
animation-range Property
Control which portion of the scroll timeline triggers the animation. By default, animations run for the entire scroll range (0-100%), but you can customize this.
Basic Syntax
/* Run animation only in specific scroll range */
.element {
animation: fadeIn linear;
animation-timeline: scroll();
animation-range: 0% 50%; /* Only first half of scroll */
}
/* Named range keywords for view() timelines */
.element {
animation: fadeIn linear;
animation-timeline: view();
animation-range: entry 0% entry 100%; /* While entering */
}
Range Keywords (for view timelines)
/* Named ranges */
animation-range: cover; /* Entire time in viewport */
animation-range: contain; /* When fully contained in viewport */
animation-range: entry; /* While entering viewport */
animation-range: exit; /* While exiting viewport */
/* With percentages */
animation-range: entry 0% entry 100%; /* Full entry phase */
animation-range: entry 50% exit 50%; /* Middle portion */
animation-range: cover 0% cover 50%; /* First half of coverage */
/* Mix with scroll distance */
animation-range: 0px 500px; /* First 500px of scroll */
animation-range: 25% 75%; /* Middle 50% of timeline */
Practical Range Examples
/* Fade in while entering, stay visible */
.fade-in-on-entry {
animation: fadeIn linear forwards;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
/* Parallax effect only while in view */
.parallax {
animation: parallaxMove linear;
animation-timeline: view();
animation-range: cover 0% cover 100%;
}
/* Animate only in middle of scroll */
.midpoint-effect {
animation: pulse ease-in-out;
animation-timeline: scroll();
animation-range: 25% 75%;
}
/* Multiple phases */
.complex-animation {
animation:
fadeIn linear forwards,
slideIn linear forwards;
animation-timeline: view(), view();
animation-range:
entry 0% entry 50%,
entry 50% entry 100%;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes parallaxMove {
from { transform: translateY(100px); }
to { transform: translateY(-100px); }
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.2); }
}
Shorthand Properties
/* Individual properties */
.element {
animation-range-start: 0%;
animation-range-end: 50%;
}
/* Shorthand */
.element {
animation-range: 0% 50%;
}
/* With named ranges */
.element {
animation-range-start: entry 0%;
animation-range-end: entry 100%;
}
/* Shorthand with names */
.element {
animation-range: entry 0% entry 100%;
}
Named Scroll Timelines
Create named scroll timelines that can be referenced by multiple elements.
scroll-timeline-name & scroll-timeline-axis
/* Define named timeline on scroll container */
.scroll-container {
scroll-timeline-name: --my-scroller;
scroll-timeline-axis: block; /* or inline, x, y */
}
/* Use the named timeline */
.animated-element {
animation: slideIn linear;
animation-timeline: --my-scroller;
}
/* Multiple elements can share the same timeline */
.element-1 {
animation: fadeIn linear;
animation-timeline: --my-scroller;
}
.element-2 {
animation: slideUp linear;
animation-timeline: --my-scroller;
}
@keyframes slideIn {
from { transform: translateX(-100px); }
to { transform: translateX(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { transform: translateY(50px); }
to { transform: translateY(0); }
}
view-timeline-name
/* Define named view timeline */
.observed-element {
view-timeline-name: --section;
view-timeline-axis: block;
}
/* Animate different element based on .observed-element's visibility */
.indicator {
animation: highlight linear;
animation-timeline: --section;
}
/* Navigation item highlights when section is in view */
.nav-item {
animation: activate linear;
animation-timeline: --section;
animation-range: entry 0% exit 100%;
}
@keyframes highlight {
from { background: transparent; }
to { background: #667eea; }
}
@keyframes activate {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
Practical Example: Section Navigation
/* Each section creates its own timeline */
section {
view-timeline-name: var(--section-name);
view-timeline-axis: block;
}
#intro { --section-name: --intro; }
#features { --section-name: --features; }
#pricing { --section-name: --pricing; }
#contact { --section-name: --contact; }
/* Navigation items respond to their sections */
.nav-link[href="#intro"] {
animation: navHighlight linear;
animation-timeline: --intro;
animation-range: entry 0% exit 100%;
}
.nav-link[href="#features"] {
animation: navHighlight linear;
animation-timeline: --features;
animation-range: entry 0% exit 100%;
}
@keyframes navHighlight {
0%, 100% {
color: inherit;
border-color: transparent;
}
10%, 90% {
color: #667eea;
border-color: #667eea;
}
}
Practical Examples
Example 1: Reading Progress Bar
/* Fixed progress bar at top */
.reading-progress {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(to right, #667eea, #764ba2);
transform-origin: left center;
z-index: 1000;
animation: reading-progress linear;
animation-timeline: scroll(root block);
}
@keyframes reading-progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
Example 2: Parallax Background
/* Parallax hero section */
.hero {
position: relative;
height: 100vh;
overflow: hidden;
}
.hero-background {
position: absolute;
inset: 0;
background: url('hero.jpg') center/cover;
animation: parallax-bg linear;
animation-timeline: view();
animation-range: entry 0% exit 100%;
}
@keyframes parallax-bg {
from {
transform: translateY(0) scale(1.2);
}
to {
transform: translateY(-100px) scale(1);
}
}
/* Parallax text layers */
.hero-title {
animation: parallax-title linear;
animation-timeline: view();
animation-range: entry 0% exit 100%;
}
@keyframes parallax-title {
from {
transform: translateY(0);
opacity: 1;
}
to {
transform: translateY(-50px);
opacity: 0;
}
}
Example 3: Fade-In Cards on Scroll
/* Cards fade in as they enter viewport */
.card {
opacity: 0;
animation: card-fade-in ease-out forwards;
animation-timeline: view();
animation-range: entry 0% entry 50%;
}
/* Stagger effect using nth-child */
.card:nth-child(1) { animation-delay: 0s; }
.card:nth-child(2) { animation-delay: 0.1s; }
.card:nth-child(3) { animation-delay: 0.2s; }
@keyframes card-fade-in {
from {
opacity: 0;
transform: translateY(30px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
Example 4: Horizontal Scroll Gallery
/* Horizontal scroll container */
.gallery-wrapper {
overflow-x: auto;
scroll-timeline-name: --gallery;
scroll-timeline-axis: inline;
}
.gallery {
display: flex;
gap: 2rem;
}
/* Individual items animate based on horizontal scroll */
.gallery-item {
animation: gallery-item-scale linear;
animation-timeline: --gallery;
}
@keyframes gallery-item-scale {
0% {
transform: scale(0.8);
filter: brightness(0.7);
}
50% {
transform: scale(1);
filter: brightness(1);
}
100% {
transform: scale(0.8);
filter: brightness(0.7);
}
}
↔️ Interactive Demo: Horizontal Scroll Gallery
Example 5: Rotating Logo on Scroll
/* Logo rotates as you scroll down */
.logo {
animation: rotate-on-scroll linear;
animation-timeline: scroll(root block);
transform-origin: center;
}
@keyframes rotate-on-scroll {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Color shift based on scroll */
.header {
animation: header-color linear;
animation-timeline: scroll(root block);
}
@keyframes header-color {
0% {
background: #ffffff;
color: #000000;
}
50% {
background: #667eea;
color: #ffffff;
}
100% {
background: #000000;
color: #ffffff;
}
}
Example 6: Counter Animation
/* Number counter that counts up on scroll */
.counter {
animation: count-up linear;
animation-timeline: view();
animation-range: entry 0% entry 100%;
counter-reset: num var(--num);
}
.counter::after {
content: counter(num);
animation: inherit;
animation-timeline: inherit;
animation-range: inherit;
}
@keyframes count-up {
from {
--num: 0;
}
to {
--num: 100;
}
}
/* Note: CSS counters with animations need additional
JavaScript or @property for smooth counting */
Example 7: Image Reveal Effect
/* Image reveals from left to right */
.image-reveal {
position: relative;
overflow: hidden;
}
.image-reveal img {
display: block;
width: 100%;
}
.image-reveal::after {
content: '';
position: absolute;
inset: 0;
background: #667eea;
transform-origin: left;
animation: reveal-curtain ease-in-out;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
@keyframes reveal-curtain {
0% {
transform: scaleX(1);
}
50% {
transform: scaleX(0);
}
100% {
transform: scaleX(0);
}
}
Browser Support
CSS Scroll-Driven Animations are a cutting-edge feature with growing browser support.
✅ Supported Browsers
- Chrome 115+ (Jul 2023)
- Edge 115+ (Jul 2023)
- Opera 101+ (Aug 2023)
⚠️ Production Warning: This is a cutting-edge feature with limited browser support. Always provide fallbacks and use progressive enhancement.
Feature Detection
/* CSS feature detection */
@supports (animation-timeline: scroll()) {
.element {
animation: fadeIn linear;
animation-timeline: scroll();
}
}
/* Fallback for unsupported browsers */
@supports not (animation-timeline: scroll()) {
.element {
animation: fadeIn 1s ease-out;
}
}
JavaScript Feature Detection
// Check for scroll-timeline support
if (CSS.supports('animation-timeline', 'scroll()')) {
document.body.classList.add('scroll-timeline-supported');
} else {
console.log('Scroll-timeline not supported, using fallback');
// Implement JavaScript-based scroll animations
}
Polyfills & Fallbacks
Strategies for supporting browsers without native scroll-timeline support.
Official Polyfill
<!-- Include the scroll-timeline polyfill -->
<script src="https://flackr.github.io/scroll-timeline/dist/scroll-timeline.js"></script>
<!-- Your scroll-driven animations will now work! -->
Polyfill Repository: github.com/flackr/scroll-timeline
Progressive Enhancement Strategy
/* Base state (works everywhere) */
.element {
opacity: 0;
transform: translateY(20px);
}
/* When element is visible (JavaScript fallback) */
.element.is-visible {
opacity: 1;
transform: translateY(0);
transition: all 0.6s ease-out;
}
/* Enhanced with scroll-timeline (modern browsers) */
@supports (animation-timeline: view()) {
.element {
opacity: 1;
transform: none;
transition: none;
animation: fadeInUp linear;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
.element.is-visible {
/* Override JS class in modern browsers */
opacity: 1;
transform: none;
transition: none;
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
JavaScript Fallback Example
// Only load JavaScript if scroll-timeline not supported
if (!CSS.supports('animation-timeline', 'scroll()')) {
// Intersection Observer fallback
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
}
});
}, {
threshold: 0.1
});
// Observe elements
document.querySelectorAll('.animate-on-scroll').forEach(el => {
observer.observe(el);
});
}
// For progress bars without scroll-timeline
if (!CSS.supports('animation-timeline', 'scroll()')) {
const progressBar = document.querySelector('.reading-progress');
window.addEventListener('scroll', () => {
const scrolled = window.scrollY;
const height = document.documentElement.scrollHeight - window.innerHeight;
const progress = (scrolled / height) * 100;
progressBar.style.transform = `scaleX(${progress / 100})`;
});
}
Best Practices for Fallbacks
✅ Do
- Use feature detection
- Provide meaningful fallbacks
- Test in unsupported browsers
- Consider using the polyfill
- Start with base styling
- Enhance progressively
❌ Don't
- Assume universal support
- Break core functionality
- Forget about Safari users
- Use without fallbacks
- Make content inaccessible
- Rely solely on animations
Best Practices
✅ Do
- Use
animation-fill-mode: forwardsfor reveals - Test performance on mobile devices
- Keep animations subtle and purposeful
- Provide reduced-motion alternatives
- Use
will-changesparingly - Combine with view transitions API
❌ Don't
- Animate too many elements at once
- Use complex animations on scroll
- Forget about accessibility
- Animate layout properties (use transforms)
- Create jarring or distracting effects
- Deploy without browser support checks
Accessibility Considerations
/* Respect reduced motion preferences */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
animation-timeline: auto !important;
transition-duration: 0.01ms !important;
}
}
/* Or disable specific scroll animations */
@media (prefers-reduced-motion: reduce) {
.scroll-animated {
animation: none;
opacity: 1;
transform: none;
}
}
Performance Tips
/* Animate transform and opacity (GPU-accelerated) */
@keyframes performant {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Avoid animating layout properties */
@keyframes avoid {
from {
height: 0; /* ❌ Causes layout */
width: 0; /* ❌ Causes layout */
top: 0; /* ❌ Causes layout */
}
to {
height: 100px;
width: 100px;
top: 100px;
}
}
/* Use will-change when appropriate */
.heavy-animation {
will-change: transform, opacity;
animation: slideIn linear;
animation-timeline: view();
}
/* Remove will-change after animation */
@keyframes slideIn {
from { transform: translateX(-100%); }
to {
transform: translateX(0);
will-change: auto; /* Remove hint */
}
}