Blog / CSS/JS

Scroll-driven Animations: The Complete Guide

Master scroll-triggered animations to create immersive web experiences. From Intersection Observer to native CSS scroll-timeline, discover 5 essential techniques.

Introduction

Scroll-triggered animations have become essential for creating memorable web experiences. They guide the user's attention, tell a visual story, and add an interactive dimension to your pages.

In this complete guide, we'll explore 5 essential techniques for implementing scroll-driven animations, from the most classic (Intersection Observer) to the most modern (CSS scroll-timeline). Each technique comes with an interactive demo and the complete code.

💡
Look at this page!

The progress bar at the top of the page and the reading tracker in the table of contents are concrete examples of scroll-driven animations. Scroll to see them in action!

1. Scroll fade-in with Intersection Observer

The Intersection Observer API is the most reliable and performant method for detecting when an element enters the viewport. Unlike older techniques based on scroll events, it does not block the main thread.

Interactive demo

Rapide

Smooth animation

Performant

Zero jank

Compatible

All browsers

fade-in-observer.js
// Observer configuration
const observerOptions = {
  root: null,          // default viewport
  rootMargin: '0px',  // margin around viewport
  threshold: 0.1       // 10% visible to trigger
};

// Callback when an element enters/exits the viewport
const handleIntersect = (entries, observer) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      // Element visible: add class
      entry.target.classList.add('visible');

      // Optional: stop observing after animation
      observer.unobserve(entry.target);
    }
  });
};

// Create the observer
const observer = new IntersectionObserver(handleIntersect, observerOptions);

// Observe all elements with [data-animate]
document.querySelectorAll('[data-animate]').forEach(el => {
  observer.observe(el);
});
fade-in-styles.css
/* Initial state: invisible and shifted down */
.fade-card {
  opacity: 0;
  transform: translateY(30px);
  transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}

/* Final state: visible and in place */
.fade-card.visible {
  opacity: 1;
  transform: translateY(0);
}

/* Cascade delay for stagger effect */
.fade-card:nth-child(2) { transition-delay: 0.1s; }
.fade-card:nth-child(3) { transition-delay: 0.2s; }
.fade-card:nth-child(4) { transition-delay: 0.3s; }

Key Intersection Observer parameters

  • root: The parent element serving as the viewport (null = browser viewport)
  • rootMargin: Margin around the root (e.g., "-100px" to trigger 100px before)
  • threshold: Required visibility percentage (0.1 = 10%, 1 = 100%)

2. Parallax effect

The parallax effect creates an illusion of depth by moving different layers at different speeds while scrolling. It's a classic of modern web design.

Scroll to see the effect
Parallax
parallax.js
// Elements with different parallax speeds
const layers = [
  { element: document.querySelector('.parallax-stars'), speed: 0.2 },
  { element: document.querySelector('.parallax-moon'), speed: 0.3 },
  { element: document.querySelector('.mountain-1'), speed: 0.5 },
  { element: document.querySelector('.mountain-2'), speed: 0.7 },
];

// Optimized function with requestAnimationFrame
let ticking = false;

function updateParallax() {
  const scrollY = window.scrollY;

  layers.forEach(layer => {
    const yOffset = scrollY * layer.speed;
    layer.element.style.transform = `translateY(${yOffset}px)`;
  });

  ticking = false;
}

window.addEventListener('scroll', () => {
  if (!ticking) {
    window.requestAnimationFrame(updateParallax);
    ticking = true;
  }
});
⚠️
Critical performance

Always use requestAnimationFrame for scroll-linked animations. Without this optimization, you risk jank that degrades the user experience.

3. Reading progress bar

The reading progress bar is an excellent visual indicator for long articles. It shows the user where they are in their reading.

Simulation
0%
Reading progress
reading-progress.js
const progressBar = document.querySelector('.reading-progress');
const article = document.querySelector('.article-content');

function updateProgress() {
  // Article position
  const articleTop = article.getBoundingClientRect().top + window.scrollY;
  const articleHeight = article.offsetHeight;
  const windowHeight = window.innerHeight;

  // Percentage calculation
  const scrolled = window.scrollY - articleTop + windowHeight;
  const total = articleHeight + windowHeight;
  const progress = Math.min(Math.max(scrolled / total, 0), 1);

  // Update the progress bar
  progressBar.style.width = `${progress * 100}%`;
}

// Listen to scroll with throttling
let rafId = null;
window.addEventListener('scroll', () => {
  if (rafId) return;
  rafId = requestAnimationFrame(() => {
    updateProgress();
    rafId = null;
  });
});
reading-progress.css
.reading-progress {
  position: fixed;
  top: 0;
  left: 0;
  width: 0%;
  height: 3px;
  background: linear-gradient(
    90deg,
    #6366f1,
    #8b5cf6,
    #d946ef
  );
  z-index: 9999;
  transition: width 0.1s ease-out;
}

4. Reveal animations (slide, scale)

Reveal animations make elements appear in different ways: from the left, right, with a zoom effect, or even with a rotation. Here is an example with several variants.

Interactive demo

Slide Left

Enters from the left

Slide Right

Enters from the right

Scale

Zoom from the center

Rotate

Rotation + scale

reveal-animations.css
/* Slide from the left */
.reveal-slide-left {
  opacity: 0;
  transform: translateX(-50px);
  transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}

/* Slide from the right */
.reveal-slide-right {
  opacity: 0;
  transform: translateX(50px);
  transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}

/* Scale from center */
.reveal-scale {
  opacity: 0;
  transform: scale(0.8);
  transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}

/* Rotation + scale */
.reveal-rotate {
  opacity: 0;
  transform: rotate(-10deg) scale(0.9);
  transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}

/* Common visible state */
.reveal-slide-left.visible,
.reveal-slide-right.visible,
.reveal-scale.visible,
.reveal-rotate.visible {
  opacity: 1;
  transform: translateX(0) scale(1) rotate(0);
}

Staggered animation (stagger)

To create an elegant stagger effect, use transition-delay with an incremental value:

stagger-delay.js
// Add incremental delay to each element
const items = document.querySelectorAll('[data-animate="reveal"]');

items.forEach((item, index) => {
  item.style.transitionDelay = `${index * 0.1}s`;
});

5. Native CSS scroll-timeline

The CSS Scroll-driven Animations specification allows animations to be linked directly to scroll progress, without JavaScript. It is the most performant method as it runs entirely on the GPU.

Native CSS (Chrome 115+)

Scroll-linked animation

Scroll the page to see the transformation

Chrome 115+ / Edge 115+
scroll-timeline.css
/* Animation keyframes classique */
@keyframes scrollReveal {
  from {
    opacity: 0;
    transform: translateY(100px) scale(0.8);
  }
  to {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
}

.scroll-animated-element {
  /* Link animation to page scroll */
  animation: scrollReveal linear;
  animation-timeline: scroll();

  /* Control when the animation plays */
  animation-range: entry 0% cover 50%;
}

animation-timeline options

  • scroll(): Linked to the document scroll timeline
  • scroll(root): Linked to the root document scroll
  • scroll(nearest): Linked to the nearest scrollable container
  • view(): Linked to the element's visibility in the viewport

animation-range options

  • entry: When the element enters the viewport
  • exit: When the element exits the viewport
  • cover: When the element covers the viewport
  • contain: When the element is fully visible
🔮
Browser compatibility

Scroll-timeline is supported in Chrome 115+ and Edge 115+. For broader compatibility, use Intersection Observer with a fallback.

Best practices and accessibility

Performance

  • Use transform and opacity: These are composite properties animated on the GPU
  • Avoid animating width, height, top, left: They trigger costly reflows
  • Throttling with requestAnimationFrame: Essential for scroll-linked animations
  • will-change sparingly: Useful but consumes GPU memory

Accessibility

Always respect user preferences regarding animations:

accessibility.css
/* Disable animations if user requests it */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }

  /* Display animated elements directly */
  .fade-card,
  .reveal-item {
    opacity: 1 !important;
    transform: none !important;
  }
}
check-motion-preference.js
// Check user preference in JavaScript
const prefersReducedMotion = window.matchMedia(
  '(prefers-reduced-motion: reduce)'
);

if (prefersReducedMotion.matches) {
  // Disable JavaScript animations
  console.log('Animations reduites');
}

// Listen for preference changes
prefersReducedMotion.addEventListener('change', () => {
  // Dynamically enable/disable
});

Techniques comparison table

Technique Performance Compatibility Use case
Intersection Observer Excellent 97%+ Reveal, lazy loading
Scroll event + rAF Good 100% Parallax, progress bar
CSS scroll-timeline Optimal ~75% Complex animations

Conclusion

Scroll-driven animations are a powerful tool for creating immersive web experiences. With the 5 techniques presented in this guide, you have everything you need to implement performant and accessible animations.

Remember these essential points:

  • Intersection Observer for simple reveal animations
  • requestAnimationFrame for parallax and progress bars
  • CSS scroll-timeline for complex animations (with fallback)
  • Always respect prefers-reduced-motion
🚀
Explore our library

Find dozens of scroll effects ready to use in our effects library, with copyable and customizable code.