| 4 min read

Scroll-Triggered Animations with IntersectionObserver

IntersectionObserver JavaScript animations performance frontend

The Problem with Scroll Event Listeners

The naive approach to scroll-triggered animations uses a scroll event listener that checks element positions on every scroll. This fires dozens of times per second and recalculates layouts, causing jank and poor performance especially on mobile devices:

// Bad: fires constantly during scroll
window.addEventListener('scroll', () => {
  document.querySelectorAll('.animate-on-scroll').forEach(el => {
    const rect = el.getBoundingClientRect();
    if (rect.top < window.innerHeight) {
      el.classList.add('visible');
    }
  });
});

This works, but it is inefficient. Every scroll event triggers layout recalculation for every animated element. On a page with 20 animated elements, that is significant overhead.

IntersectionObserver: The Right Tool

The IntersectionObserver API was designed specifically for detecting when elements enter or leave the viewport. It runs asynchronously, does not block the main thread, and is far more performant than scroll listeners.

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      entry.target.classList.add('visible');
      observer.unobserve(entry.target); // Stop watching after animation
    }
  });
}, {
  threshold: 0.1,  // Trigger when 10% visible
  rootMargin: '0px 0px -50px 0px'  // Offset from viewport edge
});

// Observe all animated elements
document.querySelectorAll('.animate-on-scroll').forEach(el => {
  observer.observe(el);
});

The key difference: IntersectionObserver only fires when an element's visibility actually changes, not on every scroll pixel. The browser optimizes the intersection checks internally.

Configuration Options

  • threshold: How much of the element must be visible to trigger. 0.1 means 10%. An array like [0, 0.25, 0.5, 0.75, 1] triggers at multiple visibility levels.
  • rootMargin: Offsets applied to the viewport boundary. Negative bottom margin means elements trigger before they fully enter the viewport, creating a more natural feel.
  • root: The scrollable container. Defaults to the viewport. Set this if your content scrolls inside a specific container.

The CSS Animations

I define animations using CSS classes. Elements start in a hidden state and transition when the visible class is added:

/* Initial hidden state */
.animate-on-scroll {
  opacity: 0;
  transform: translateY(30px);
  transition: opacity 0.6s ease-out, transform 0.6s ease-out;
}

/* Visible state */
.animate-on-scroll.visible {
  opacity: 1;
  transform: translateY(0);
}

This creates a fade-up effect. The element starts 30px below its final position with zero opacity, then smoothly moves into place.

Animation Variants

I define several animation presets using data attributes:

/* Fade in from left */
.animate-on-scroll[data-animation="fade-left"] {
  opacity: 0;
  transform: translateX(-30px);
}

/* Fade in from right */
.animate-on-scroll[data-animation="fade-right"] {
  opacity: 0;
  transform: translateX(30px);
}

/* Scale up */
.animate-on-scroll[data-animation="scale"] {
  opacity: 0;
  transform: scale(0.9);
}

/* All variants share the visible state */
.animate-on-scroll.visible {
  opacity: 1;
  transform: translateY(0) translateX(0) scale(1);
}

Usage in HTML:

<div class="animate-on-scroll" data-animation="fade-left">
  Content here
</div>

Staggered Animations

When multiple elements appear at the same time (like a grid of cards), staggering the animations creates a cascading effect that looks polished:

/* Apply increasing delays to sibling elements */
.animate-on-scroll[data-delay="1"] { transition-delay: 0.1s; }
.animate-on-scroll[data-delay="2"] { transition-delay: 0.2s; }
.animate-on-scroll[data-delay="3"] { transition-delay: 0.3s; }
.animate-on-scroll[data-delay="4"] { transition-delay: 0.4s; }

Or generate delays dynamically in JavaScript:

document.querySelectorAll('.stagger-group').forEach(group => {
  const children = group.querySelectorAll('.animate-on-scroll');
  children.forEach((child, index) => {
    child.style.transitionDelay = `${index * 0.1}s`;
  });
});

This applies a 100ms incremental delay to each child element, creating a smooth cascade regardless of how many elements are in the group.

Respecting User Preferences

Some users experience motion sickness from animations. Always respect the prefers-reduced-motion media query:

@media (prefers-reduced-motion: reduce) {
  .animate-on-scroll {
    opacity: 1;
    transform: none;
    transition: none;
  }
}

This disables all scroll animations for users who have requested reduced motion in their system preferences. You can also check this preference in JavaScript to avoid unnecessary observer setup:

const prefersReducedMotion = window.matchMedia(
  '(prefers-reduced-motion: reduce)'
).matches;

if (!prefersReducedMotion) {
  // Set up IntersectionObserver
  initScrollAnimations();
} else {
  // Make everything visible immediately
  document.querySelectorAll('.animate-on-scroll').forEach(el => {
    el.classList.add('visible');
  });
}

Performance Best Practices

  • Animate only opacity and transform: These properties are GPU-accelerated and do not trigger layout recalculation. Avoid animating width, height, margin, or padding.
  • Unobserve after triggering: Once an element has animated in, stop watching it. This reduces the observer's workload as the user scrolls.
  • Use will-change sparingly: Adding will-change: transform, opacity tells the browser to prepare for animation, but applying it to too many elements wastes GPU memory.
  • Keep animations short: 300-600ms is the sweet spot. Longer animations feel sluggish, shorter ones are hard to notice.

My Implementation

On my portfolio site, I use scroll-triggered animations for section headings, project cards, skill badges, and timeline entries. The total JavaScript is about 20 lines (the IntersectionObserver setup). The CSS adds another 30 lines. The performance impact is near zero because IntersectionObserver is incredibly efficient.

The result is a site that feels alive and responsive to scrolling, without the performance penalty of traditional scroll event listeners. IntersectionObserver is one of those browser APIs that does exactly what you need with minimal code. It has excellent browser support (97%+ globally) and requires no polyfills for modern browsers.