Design.dev design.dev

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)

📜 Scroll inside this container - the target stays visible and rotates 720°
Scroll Down ↓ Watch the target spin
🎯
0° rotation
180° ↻
360° ↻
540° ↻
720° - Two full rotations!
🎉 Complete!
⚠️ Your browser doesn't support scroll-timeline. View in Chrome 115+ to see the effect.

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)

📜 Scroll inside this container to see items fade in as they enter the viewport
Scroll to reveal items ↓
Regular Item
I fade in when visible!
Regular Item
Me too!
Regular Item
And me!
Regular Item
🎉 Last one!
⚠️ Your browser doesn't support view timeline. View in Chrome 115+ to see the effect.

🎯 Interactive Demo: Scale & Rotate on Entry

📜 Scroll inside this container to see items scale up and rotate into view
Scroll to see scale effect ↓
Regular Item
I scale up!
Regular Item
Watch me grow!
Regular Item
Bigger and better!
🏁 Finish
⚠️ Your browser doesn't support view timeline. View in Chrome 115+ to see the effect.

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

📜 Scroll horizontally to see items scale up when centered
Item 1
Item 2
Item 3
Item 4
Item 5
Item 6
Item 7
🎉 End
⚠️ Your browser doesn't support scroll-timeline. View in Chrome 115+ to see the effect.

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)
Currently Chromium-based browsers only

In Development

  • Firefox Flag required
  • Safari Not yet supported
Check Can I Use for latest status

⚠️ 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: forwards for reveals
  • Test performance on mobile devices
  • Keep animations subtle and purposeful
  • Provide reduced-motion alternatives
  • Use will-change sparingly
  • 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 */
  }
}