JavaScript Intersection Observer

The JavaScript Intersection Observer API tells you when an element crosses a visibility boundary relative to the browser viewport or a scrollable ancestor. It handles common tasks such as lazy loading, scroll-triggered effects, infinite scrolling, active navigation, and viewability measurement without repeatedly calculating element positions during every scroll event.

Intersection information arrives asynchronously. It is suitable for visibility decisions, but it does not provide pixel-perfect, immediate overlap tracking.

Create an IntersectionObserver

Create one observer with a callback and options, then call observe() for each target. The callback receives a batch of IntersectionObserverEntry objects.

Example:

const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    // Toggle the class whenever the target crosses the threshold.
    entry.target.classList.toggle("visible", entry.isIntersecting);
  });
}, { threshold: 0.25 });

document.querySelectorAll(".card").forEach((card) => {
  // One observer can watch many targets with the same options.
  observer.observe(card);
});

Understand the Observer Options

Option Purpose
root Uses an element or document as the intersection boundary; null means the viewport
rootMargin Expands or shrinks the root's calculation rectangle
threshold Sets one ratio or an array of ratios that trigger notifications when crossed

Set the options when constructing the observer. All targets watched by that observer share them.

Use isIntersecting and intersectionRatio

isIntersecting reports whether the target currently intersects the root. intersectionRatio reports the visible intersection area divided by the target's bounding-box area, from 0 to 1.

const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    // Log how much of each section is inside the root.
    console.log(entry.target.id, entry.intersectionRatio);
  });
}, { threshold: [0, 0.25, 0.5, 0.75, 1] });

Reveal Content Once

When an effect should run only once, stop observing the target after it becomes visible. This avoids later callbacks for completed work.

Run this example:

<article class="card">Aarav builds accessible interfaces.</article>
<article class="card">Olivia creates useful design systems.</article>

<script>
// Reveal each card after it enters the viewport.
const observer = new IntersectionObserver((entries, currentObserver) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      entry.target.classList.add("visible");
      currentObserver.unobserve(entry.target);
    }
  });
}, { threshold: 0.2 });

document.querySelectorAll(".card").forEach((card) => observer.observe(card));
</script>

Lazy Load an Image

Native image loading is often the simplest option, but Intersection Observer is useful when you need custom loading behavior. Store the real URL in a data attribute and assign it before the image reaches the viewport.

const imageObserver = new IntersectionObserver((entries, observer) => {
  entries.forEach((entry) => {
    if (!entry.isIntersecting) return;
    const image = entry.target;
    // Start loading the real source stored in data-src.
    image.src = image.dataset.src;
    image.removeAttribute("data-src");
    observer.unobserve(image);
  });
}, { rootMargin: "300px 0px" });

document.querySelectorAll("img[data-src]").forEach((image) => imageObserver.observe(image));

Positive vertical rootMargin starts loading before the image becomes visible, giving the resource time to arrive.

Observe Inside a Scrollable Container

Set root to a scrollable ancestor when visibility should be measured inside that element rather than the page viewport. The observed target must be within the root's containing hierarchy.

const panel = document.querySelector(".message-panel");
const finalMessage = panel.querySelector(".message:last-child");

const panelObserver = new IntersectionObserver((entries) => {
  // Enable the control when the final message appears in the panel.
  document.querySelector("#continue").disabled = !entries[0].isIntersecting;
}, { root: panel, threshold: 1 });

panelObserver.observe(finalMessage);

Implement Infinite Scrolling with a Sentinel

Place a small sentinel after the current results. Load another page when the sentinel intersects, prevent overlapping requests, and disconnect the observer when no data remains.

let loading = false;
const sentinel = document.querySelector("#load-more-sentinel");

const listObserver = new IntersectionObserver(async ([entry]) => {
  if (!entry.isIntersecting || loading) return;
  loading = true;
  try {
    // Fetch and append the next result page.
    const hasMore = await appendNextPage();
    if (!hasMore) listObserver.disconnect();
  } finally {
    loading = false;
  }
}, { rootMargin: "400px 0px" });

listObserver.observe(sentinel);

Highlight the Active Section

For scroll-based navigation, observe every section and choose the intersecting entry with the strongest ratio. A negative bottom root margin can create a smaller active zone near the top of the viewport.

const sectionObserver = new IntersectionObserver((entries) => {
  const visible = entries
    .filter((entry) => entry.isIntersecting)
    .sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
  if (visible) {
    // Mark the link associated with the most visible changed section.
    document.querySelectorAll("nav a").forEach((link) => {
      link.classList.toggle("active", link.hash === "#" + visible.target.id);
    });
  }
}, { threshold: [0.25, 0.5, 0.75] });

document.querySelectorAll("section[id]").forEach((section) => sectionObserver.observe(section));

Stop Observing Targets

  • unobserve(target) stops watching one element.
  • disconnect() stops watching every target assigned to the observer.
  • takeRecords() returns pending queued entries before they are delivered.

Clean up observers when a component is removed or when the task finishes.

The API reports geometric intersection, not guaranteed human visibility. A target may intersect while another element covers it. The newer visibility-tracking options are more expensive and still require a browser-support check.

Common Intersection Observer Mistakes

  • Using thresholds instead of the constructor option named threshold.
  • Creating one observer for every target when they can share options.
  • Expecting a callback for every pixel of scrolling.
  • Forgetting to prevent duplicate asynchronous loads.
  • Leaving completed or removed targets under observation.
  • Assuming intersection means the element is unobstructed and actually seen.

Conclusion

Intersection Observer provides efficient asynchronous visibility changes for elements relative to a viewport or container. Choose the correct root, use margins to start work early, set meaningful thresholds, reuse observers, and clean them up. These choices support responsive lazy loading, reveal effects, navigation, and endless lists without continuous scroll-position calculations.



Found This Page Useful? Share It!
Get the Latest Tutorials and Updates
Join us on Telegram