W3docs

JavaScript Intersection Observer API

Learn the JavaScript Intersection Observer API to detect when an element enters or leaves the viewport — for lazy loading, infinite scroll, and animations.

The Intersection Observer API lets you ask the browser to tell you when an element enters or leaves the visible part of the page. It does this efficiently and asynchronously, without the performance cost of watching scroll events yourself. It is the right tool for lazy-loading images, building infinite scroll, triggering animations as content appears, and measuring whether an ad or banner was actually seen.

The Problem It Solves

Before this API existed, answering the simple question "is this element on screen right now?" was surprisingly painful. You had to attach a listener to the scroll (and often resize) event, then call getBoundingClientRect() on each tracked element to compare its position against the viewport.

// The old, expensive way — runs on every scroll tick.
window.addEventListener('scroll', () => {
  const rect = element.getBoundingClientRect();
  const inView = rect.top < window.innerHeight && rect.bottom > 0;
  if (inView) {
    // do something
  }
});

Scroll events fire dozens of times per second, and getBoundingClientRect() forces the browser to recalculate layout (a "reflow"). Doing that work synchronously on the main thread during a scroll is a classic source of jank. (See Event Handling in the DOM and JavaScript Scrolling for how these events behave.)

IntersectionObserver flips the model around. Instead of you polling positions, the browser watches the elements for you and calls back only when visibility actually changes. The work happens off the main thread, so it doesn't block scrolling. For more on why this matters, read DOM Performance Optimization. It is a close sibling of the MutationObserver API, which watches the DOM structure for changes rather than visibility.

Basic Usage

You create an observer with a callback, then tell it which elements to watch with observe().

// 1. Create an observer with a callback and (optional) options.
const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      console.log('Element is now visible:', entry.target);
    } else {
      console.log('Element left the viewport:', entry.target);
    }
  });
});

// 2. Start watching a target element.
const target = document.querySelector('#box');
observer.observe(target);

The callback receives an array of entries, one per observed element whose visibility changed. A single observer can watch many elements, and that is the recommended pattern — create one observer and call observe() for each target rather than creating one observer per element.

Info

The callback runs asynchronously and changes are batched — the browser may report several entries in one call. It also fires once right after you start observing, so you get the element's initial visibility state without waiting for a scroll. Browser support is excellent across all modern browsers.

Configuring the Observer

The second argument to the constructor is an options object with three properties.

root

The element used as the viewport for checking visibility. The target must be a descendant of the root. When root is null (the default), the browser's own viewport is used.

const observer = new IntersectionObserver(callback, {
  root: document.querySelector('#scroll-container'),
});

rootMargin

A margin around the root, written like a CSS margin value. It grows or shrinks the box used for intersection checks. A common trick is a positive bottom margin so elements are reported as "visible" before they actually scroll into view — useful for loading content early.

const observer = new IntersectionObserver(callback, {
  // Trigger 200px before the element reaches the bottom edge.
  rootMargin: '0px 0px 200px 0px',
});

threshold

A number from 0 to 1, or an array of numbers, telling the observer at what visibility ratios to fire. 0 means "fire as soon as a single pixel is visible," 1 means "fire only when the element is fully visible." An array fires at each listed ratio.

const observer = new IntersectionObserver(callback, {
  // Fire at 0%, 50%, and 100% visibility.
  threshold: [0, 0.5, 1],
});

What's Inside an Entry

Each object in the entries array describes one element's visibility at the moment the callback ran. The most useful properties are:

  • isIntersecting — a boolean: true if the element is currently visible within the root.
  • intersectionRatio — how much of the element is visible, from 0 to 1.
  • target — the element being observed.
  • boundingClientRect — the target's size and position.
  • intersectionRect — the visible portion of the target.
  • rootBounds — the rectangle of the root (adjusted by rootMargin).
  • time — a timestamp of when the change was recorded.
const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    console.log(entry.target.id, 'visible:', entry.isIntersecting);
    console.log('ratio:', entry.intersectionRatio.toFixed(2));
  }
});

Methods: observe, unobserve, disconnect

An observer instance gives you three methods:

  • observe(element) — start watching an element.
  • unobserve(element) — stop watching one element.
  • disconnect() — stop watching all elements at once.

A key best practice: once an element has done its one-time job — for example, an image that has finished lazy-loading — call unobserve() on it so the browser stops tracking something that will never change again.

Use Case 1 — Lazy Loading Images

Lazy loading defers image downloads until they are about to be seen. Put the real URL in a data-src attribute, observe each image, and swap it into src when it becomes visible — then stop observing it.

<img data-src="photo-1.jpg" alt="First photo" width="600" height="400" />
<img data-src="photo-2.jpg" alt="Second photo" width="600" height="400" />
<img data-src="photo-3.jpg" alt="Third photo" width="600" height="400" />
const images = document.querySelectorAll('img[data-src]');

const imageObserver = new IntersectionObserver((entries, observer) => {
  entries.forEach((entry) => {
    if (!entry.isIntersecting) return;

    const img = entry.target;
    img.src = img.dataset.src;        // load the real image
    img.removeAttribute('data-src');
    observer.unobserve(img);          // job done — stop watching it
  });
}, { rootMargin: '0px 0px 200px 0px' }); // start loading a little early

images.forEach((img) => imageObserver.observe(img));
Note

Modern browsers also support the native loading="lazy" attribute on <img> and <iframe>, which needs no JavaScript at all. Reach for IntersectionObserver when you need custom behavior — a placeholder swap, a fade-in, or loading non-image content.

Use Case 2 — Infinite Scroll

For infinite scroll, place an empty "sentinel" element at the bottom of the list. When that sentinel scrolls into view, load the next page of data and append it. Because the sentinel stays at the bottom, the same observer keeps firing as the user scrolls further.

<ul id="list"></ul>
<div id="sentinel"></div>
const list = document.querySelector('#list');
const sentinel = document.querySelector('#sentinel');
let page = 1;
let loading = false;

async function loadMore() {
  if (loading) return;            // guard against overlapping loads
  loading = true;

  const res = await fetch('/api/items?page=' + page);
  const items = await res.json();

  items.forEach((item) => {
    const li = document.createElement('li');
    li.textContent = item.title;
    list.appendChild(li);
  });

  page += 1;
  loading = false;
}

const scrollObserver = new IntersectionObserver((entries) => {
  if (entries[0].isIntersecting) {
    loadMore();
  }
});

scrollObserver.observe(sentinel);

Use Case 3 — Reveal-on-Scroll Animations

A popular effect is to fade or slide elements in as they enter the viewport. Keep the animation in CSS and let JavaScript add a class at the right moment.

.reveal {
  opacity: 0;
  transform: translateY(20px);
  transition: opacity 0.6s ease, transform 0.6s ease;
}
.reveal.is-visible {
  opacity: 1;
  transform: translateY(0);
}
const revealItems = document.querySelectorAll('.reveal');

const revealObserver = new IntersectionObserver((entries, observer) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      entry.target.classList.add('is-visible');
      observer.unobserve(entry.target); // animate only once
    }
  });
}, { threshold: 0.15 }); // fire when ~15% is showing

revealItems.forEach((el) => revealObserver.observe(el));

Use Case 4 — Impression / Visibility Tracking

Analytics often need to know whether content was actually seen, not just present in the DOM. A higher threshold lets you record an impression only when a meaningful portion of an element is visible.

const adObserver = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.intersectionRatio >= 0.5) {
      sendImpression(entry.target.dataset.adId);
      adObserver.unobserve(entry.target); // count each ad once
    }
  });
}, { threshold: 0.5 }); // at least 50% visible

document.querySelectorAll('.ad').forEach((ad) => adObserver.observe(ad));

You could extend this with a timer to require, say, one full second of 50% visibility before counting an impression — a common standard for "viewable" ads.

Summary

The Intersection Observer API replaces fragile, performance-hungry scroll listeners with a clean, asynchronous way to react to element visibility. Create one observer, point it at your targets with observe(), read isIntersecting and intersectionRatio in the callback, and call unobserve() once an element's work is finished. With root, rootMargin, and threshold you can tune exactly when it fires — making lazy loading, infinite scroll, scroll animations, and impression tracking both simple and smooth.

Test Your Knowledge

Practice
Which entry property tells you whether the target is currently visible within the root?
Which entry property tells you whether the target is currently visible within the root?
Practice
What is the main reason IntersectionObserver is preferred over a scroll listener that calls getBoundingClientRect()?
What is the main reason IntersectionObserver is preferred over a scroll listener that calls getBoundingClientRect()?
Practice
Which statements about the constructor options are correct?
Which statements about the constructor options are correct?
Was this page helpful?