← thecodex.expert · The Codex Family of Knowledge
The Codex / JavaScript / Intersection Observer

Intersection Observer

Efficiently detect when elements enter or leave the viewport — lazy loading images, scroll animations, infinite scroll, and sticky headers.

What it is, in plain English: The Intersection Observer API lets you observe when one element intersects with another (or with the viewport). Instead of listening to scroll events and measuring element positions (slow and janky), you declare what you want to watch and a callback fires only when visibility changes. This is how lazy loading, scroll-triggered animations, and infinite scroll are built in modern JavaScript.
Where this bites you

observe() reports the element's state straight away. As soon as you start watching an element, the callback fires once with its current visibility, and for anything below the fold that's isIntersecting: false. Code that treats "not intersecting" as "the user scrolled away" (pausing a video, logging an analytics event, unloading content) runs on page load, for things the user never saw.

// #promo sits far below the fold
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (!entry.isIntersecting) {
      console.log("user scrolled away from", entry.target.id);   // e.g. pause a video, send analytics
    } else {
      console.log("visible:", entry.target.id);
    }
  });
});
observer.observe(document.querySelector("#promo"));
// on load, before any scrolling:
// user scrolled away from promo     <- the user never saw it
// (scroll down, then back up)
// visible: promo
// user scrolled away from promo

Watch elements — without scroll events

The old way to detect if something is visible — listening to the scroll event and calling getBoundingClientRect() every pixel — fires hundreds of times per second and forces layout recalculation each time. Intersection Observer does the same thing as part of the browser's own rendering work, and fires only when visibility actually changes.

Basic usage

// Create an observer with a callback and options:
const observer = new IntersectionObserver((entries) => {
  // 'entries' = array of elements whose visibility changed
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      console.log(entry.target, "is now visible!");
    } else {
      console.log(entry.target, "is now hidden");
    }
  });
}, {
  threshold: 0.1,  // fire when 10% of element is visible
});

// Watch elements:
const section = document.querySelector("#hero");
observer.observe(section);

// Stop watching:
observer.unobserve(section);

// Stop all observations:
observer.disconnect();

The basic example above logs "is now hidden" on load for the same reason. That's harmless for logging, but not for real actions. Only treat "not intersecting" as leaving once the element has actually been visible:

const seen = new WeakSet();                       // elements that have actually been on screen

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      seen.add(entry.target);
      console.log("visible:", entry.target.id);
    } else if (seen.has(entry.target)) {          // ignore the initial "not visible" report
      console.log("user scrolled away from", entry.target.id);
    }
  });
});
observer.observe(document.querySelector("#promo"));
// on load: nothing
// visible: promo
// user scrolled away from promo

Lazy loading images (the main use case)

<!-- HTML: use data-src instead of src (placeholder prevents layout shift): -->
<img class="lazy" data-src="/real-image.jpg" src="/placeholder.jpg" alt="...">
// JavaScript: load the real image when it's about to enter the viewport:
const imgObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (!entry.isIntersecting) return;  // skip if not visible

    const img = entry.target;
    img.src = img.dataset.src;       // swap to real URL
    img.classList.remove("lazy");
    imgObserver.unobserve(img);      // done — stop watching this image
  });
}, {
  rootMargin: "200px 0px",  // start loading 200px BEFORE the image enters viewport
});

// Watch all lazy images:
document.querySelectorAll("img.lazy").forEach(img => imgObserver.observe(img));

Scroll animations

/* CSS: elements start invisible */
.fade-in {
  opacity: 0;
  transform: translateY(30px);
  transition: opacity 0.6s ease, transform 0.6s ease;
}
.fade-in.visible {
  opacity: 1;
  transform: translateY(0);
}
// JavaScript: add class when element enters viewport
const animObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      entry.target.classList.add("visible");
      animObserver.unobserve(entry.target);  // animate only once
    }
  });
}, { threshold: 0.15 });

document.querySelectorAll(".fade-in").forEach(el => animObserver.observe(el));
Try It Yourself

Official Sources

The Codex links only to official documentation and the ECMAScript specification. No third-party tutorials.