JavaScript Intersection Observer Tutorial: Lazy Load Images and Animate on Scroll

Why You Need the Intersection Observer API in 2026

If you have ever needed to detect when an element enters or leaves the viewport, you probably relied on scroll event listeners and getBoundingClientRect(). That approach works, but it is expensive. Every scroll tick fires calculations on the main thread, and performance suffers quickly on content-heavy pages.

The Intersection Observer API solves this problem. It provides a native, asynchronous way to observe changes in the intersection of a target element with an ancestor element or the top-level document viewport. No libraries required, no scroll-event spaghetti, and far better performance.

In this JavaScript Intersection Observer tutorial, you will learn exactly how the API works, then apply it to two of the most common real-world use cases frontend developers face every day: lazy loading images and triggering animations on scroll.

What Is the Intersection Observer?

The Intersection Observer is a browser API that lets you register a callback function that fires whenever a target element crosses a defined threshold of visibility inside a root container (usually the viewport). Instead of polling on every frame, the browser itself tells you when something becomes visible.

Key Concepts at a Glance

Concept Description
Root The element used as the viewport for checking visibility. Defaults to the browser viewport when set to null.
Root Margin Margin around the root. Works like CSS margin values (e.g., "0px 0px 200px 0px") and lets you expand or shrink the detection area.
Threshold A number or array of numbers between 0 and 1 indicating what percentage of the target must be visible before the callback fires. 0 means any pixel, 1 means fully visible.
Entry The object passed to the callback for each observed element. Contains properties like isIntersecting, intersectionRatio, and target.

Step 1: Understanding the Basic Syntax

Before building anything, let’s look at the minimal code needed to create an observer.

// 1. Define options
const options = {
  root: null,           // viewport
  rootMargin: '0px',
  threshold: 0.1        // trigger when 10% visible
};

// 2. Create callback
function handleIntersect(entries, observer) {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      console.log('Element is visible:', entry.target);
    }
  });
}

// 3. Instantiate observer
const observer = new IntersectionObserver(handleIntersect, options);

// 4. Tell it what to watch
const target = document.querySelector('.my-element');
observer.observe(target);

That is the entire pattern. Everything else you will do with the Intersection Observer is a variation of these four steps.

Important Entry Properties

Inside the callback, each entry object gives you useful data:

  • entry.isIntersecting – Boolean. true when the element is inside the root bounds.
  • entry.intersectionRatio – A number from 0 to 1 showing how much of the element is currently visible.
  • entry.target – The DOM element being observed.
  • entry.boundingClientRect – The target’s bounding rectangle at the time of intersection.
  • entry.rootBounds – The root element’s bounding rectangle.

Step 2: Lazy Loading Images with Intersection Observer

Lazy loading images is one of the most impactful performance optimizations you can make. Instead of downloading every image when the page loads, you only fetch images as the user scrolls near them.

2a. The HTML Markup

Start by placing the real image URL in a data-src attribute instead of the standard src. You can use a tiny placeholder or a low-quality blur as the initial src.

<img
  class="lazy"
  src="placeholder.jpg"
  data-src="real-photo.jpg"
  alt="A description of the photo"
  width="800"
  height="600"
/>

Tip: Always include width and height attributes to prevent layout shift (CLS) while the real image loads.

2b. The JavaScript

document.addEventListener('DOMContentLoaded', () => {
  const lazyImages = document.querySelectorAll('img.lazy');

  const imageObserver = new IntersectionObserver((entries, observer) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const img = entry.target;
        img.src = img.dataset.src;
        img.classList.remove('lazy');
        img.classList.add('loaded');
        observer.unobserve(img);  // stop watching once loaded
      }
    });
  }, {
    root: null,
    rootMargin: '0px 0px 300px 0px',  // start loading 300px before visible
    threshold: 0
  });

  lazyImages.forEach(img => imageObserver.observe(img));
});

2c. How It Works

  1. We select all images with the class lazy.
  2. We create an observer with a 300px bottom root margin. This means the callback fires when an image is still 300 pixels below the viewport, giving the browser a head start on downloading.
  3. When isIntersecting is true, we swap data-src into src.
  4. We call observer.unobserve(img) to stop tracking that image. There is no reason to keep watching an image that has already loaded.

2d. Optional CSS Fade-In Effect

img.lazy {
  opacity: 0;
  transition: opacity 0.4s ease-in;
}

img.loaded {
  opacity: 1;
}

This gives a smooth appearance as each image loads into view.

What About the Native loading=”lazy” Attribute?

Modern browsers support <img loading="lazy"> natively. It is a great quick win. However, the Intersection Observer approach gives you full control over the root margin, threshold, placeholder strategy, and animation. Use the native attribute for simple cases and the Intersection Observer when you need customization.

Step 3: Triggering Scroll Animations with Intersection Observer

Another extremely popular use case is revealing elements with CSS animations as the user scrolls down. Think of cards that slide in, text that fades up, or counters that start counting when they appear.

3a. The HTML

<section class="animate-on-scroll fade-up">
  <h2>Our Services</h2>
  <p>We build fast, accessible websites.</p>
</section>

<section class="animate-on-scroll slide-in-left">
  <h2>Our Portfolio</h2>
  <p>Take a look at our recent projects.</p>
</section>

3b. The CSS

/* Hidden state */
.animate-on-scroll {
  opacity: 0;
  transition: opacity 0.6s ease, transform 0.6s ease;
}

.fade-up {
  transform: translateY(40px);
}

.slide-in-left {
  transform: translateX(-60px);
}

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

3c. The JavaScript

const animatedElements = document.querySelectorAll('.animate-on-scroll');

const scrollObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      entry.target.classList.add('visible');
      // Optional: unobserve if animation should only play once
      scrollObserver.unobserve(entry.target);
    }
  });
}, {
  threshold: 0.15
});

animatedElements.forEach(el => scrollObserver.observe(el));

3d. How It Works

  1. Every section starts hidden via CSS (opacity: 0 plus a transform offset).
  2. The observer watches for 15% visibility (threshold: 0.15).
  3. Once the element enters the viewport far enough, the callback adds the visible class, which triggers the CSS transition.
  4. We unobserve the element so the animation only plays once. If you want it to replay every time the user scrolls back up, simply remove the unobserve call.

Step 4: Advanced Techniques

Using Multiple Thresholds

You can pass an array of thresholds to get notified at multiple stages of visibility. This is useful for progress indicators or parallax-style effects.

const observer = new IntersectionObserver(callback, {
  threshold: [0, 0.25, 0.5, 0.75, 1]
});

The callback will fire each time the element crosses any of those percentages, both entering and leaving.

Observing Inside a Scrollable Container

By default, root is the viewport. But you can set it to any scrollable ancestor element.

const container = document.querySelector('.scroll-container');

const observer = new IntersectionObserver(callback, {
  root: container,
  rootMargin: '0px',
  threshold: 0.5
});

This is handy for horizontal carousels, chat windows, or any overflow-scroll section.

Disconnecting the Observer

If you need to stop observing everything at once (for example, during a page transition in a single-page app), call:

observer.disconnect();

This removes all tracked targets in one call.

Common Use Cases Beyond Lazy Loading and Animations

The Intersection Observer is versatile. Here are other scenarios where it shines:

  • Infinite scrolling – Watch a sentinel element at the bottom of a list. When it becomes visible, fetch the next batch of data.
  • Ad viewability tracking – Measure how long an ad banner is actually visible to the user.
  • Sticky header changes – Toggle a compact header style when a hero section scrolls out of view.
  • Video autoplay/pause – Automatically play a video when it scrolls into view and pause it when it leaves.
  • Table of contents highlighting – Highlight the active section link in a sidebar navigation as the user scrolls through content.

Browser Support

As of 2026, the Intersection Observer API enjoys excellent support across all modern browsers, including Chrome, Firefox, Safari, Edge, and mobile browsers. If you need to support very old browsers, a polyfill from the W3C is available on npm (intersection-observer), but for the vast majority of projects today, native support is more than sufficient.

Performance Tips and Best Practices

  • Always unobserve elements you no longer need to track. This keeps the observer lightweight.
  • Use rootMargin to preload content. A margin of 200-400px below the viewport gives images time to download before the user reaches them.
  • Avoid heavy logic inside the callback. The callback runs on the main thread. If you need to do expensive work, use requestAnimationFrame or queue a microtask.
  • Batch your observers sensibly. One observer instance can watch many elements. You do not need a separate observer per element.
  • Test on real devices. Intersection behavior can differ slightly depending on scroll mechanics, especially on iOS Safari.

Full Working Example: Lazy Images + Scroll Animations Combined

Here is a complete, copy-paste-ready snippet that combines both techniques from this tutorial.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Intersection Observer Demo</title>
  <style>
    body { font-family: sans-serif; margin: 0; padding: 20px; }
    .spacer { height: 100vh; display: flex; align-items: center; justify-content: center; }

    /* Lazy image styles */
    img.lazy { opacity: 0; transition: opacity 0.5s ease; }
    img.loaded { opacity: 1; }

    /* Animation styles */
    .animate-on-scroll { opacity: 0; transform: translateY(30px); transition: all 0.6s ease; }
    .animate-on-scroll.visible { opacity: 1; transform: translateY(0); }
  </style>
</head>
<body>

  <div class="spacer"><p>Scroll down to see the magic.</p></div>

  <img class="lazy" src="placeholder.jpg" data-src="photo1.jpg" alt="Photo 1" width="800" height="600">

  <div class="spacer"></div>

  <section class="animate-on-scroll">
    <h2>Hello from below the fold!</h2>
    <p>This section animates into view.</p>
  </section>

  <div class="spacer"></div>

  <img class="lazy" src="placeholder.jpg" data-src="photo2.jpg" alt="Photo 2" width="800" height="600">

  <script>
    // Lazy Loading Observer
    const lazyImages = document.querySelectorAll('img.lazy');
    const imgObserver = new IntersectionObserver((entries, obs) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          const img = entry.target;
          img.src = img.dataset.src;
          img.classList.remove('lazy');
          img.classList.add('loaded');
          obs.unobserve(img);
        }
      });
    }, { rootMargin: '0px 0px 300px 0px', threshold: 0 });
    lazyImages.forEach(img => imgObserver.observe(img));

    // Scroll Animation Observer
    const animEls = document.querySelectorAll('.animate-on-scroll');
    const animObserver = new IntersectionObserver((entries, obs) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          entry.target.classList.add('visible');
          obs.unobserve(entry.target);
        }
      });
    }, { threshold: 0.15 });
    animEls.forEach(el => animObserver.observe(el));
  </script>

</body>
</html>

Frequently Asked Questions

What is an Intersection Observer in JavaScript?

The Intersection Observer is a built-in browser API that asynchronously detects when a target element enters or exits a specified root element (usually the viewport). It replaces the older, less performant pattern of listening to scroll events and manually calculating element positions.

How do I implement an Intersection Observer?

You create an instance of IntersectionObserver by passing a callback function and an options object. Then you call observer.observe(element) on each element you want to track. When any observed element crosses the configured threshold, your callback fires with an array of entry objects.

What is the difference between Mutation Observer and Intersection Observer?

A Mutation Observer watches for changes in the DOM tree, such as added or removed nodes, attribute changes, or text content changes. An Intersection Observer watches for changes in the visibility of an element relative to a root container. They serve completely different purposes and can be used together in the same project.

What is the root in Intersection Observer?

The root is the element that acts as the bounding box for intersection checks. When set to null (the default), the browser viewport is used. You can also set it to any scrollable ancestor element if you want to detect visibility within a specific container instead of the full page.

Can I observe multiple elements with one Intersection Observer?

Yes. A single IntersectionObserver instance can observe as many elements as you need. Simply call observer.observe() for each target. The callback receives all intersecting entries in a single array, making it efficient to handle many elements at once.

Is the Intersection Observer better than scroll event listeners?

In almost every case, yes. The Intersection Observer runs asynchronously off the main thread, does not cause layout thrashing, and requires far less code. Scroll event listeners fire on every frame and need manual debouncing or throttling to avoid performance problems. The only scenario where you might still need a scroll listener is when you need the exact scroll position (e.g., a progress bar), which the Intersection Observer does not provide.

Leave a Comment