IntersectionObserver

Reviewed & published by Brayan K

IntersectionObserver is a browser API that efficiently watches when an element scrolls into or out of view, running a callback only when its visibility crosses a threshold you set.

Part of the free JavaScript course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

It replaces clunky scroll-event math and powers lazy-loaded images, infinite scroll, scroll-triggered animations, and ad/impression tracking.

What You'll Learn in This Lesson

💡 About the runnable demos: The real IntersectionObserver needs a browser viewport, so it lives in the read-only code blocks. Each Try it Yourself runs the underlying visibility math in plain JavaScript — computing whether a rectangle overlaps the viewport and by how much — which is exactly what the browser does for you.

🛂 Real-World Analogy: Think of a border guard at a checkpoint. Instead of you constantly running to the border to check who is crossing (a scroll listener polling every frame), the guard simply taps you on the shoulder the moment someone steps across the line (the observer's callback). You only do work when something actually changes.

1️⃣ Observing Visibility

Create an observer with a callback, then call observe(element) for each element you care about. When an element enters or leaves the viewport, the callback receives an array of entries; each has isIntersecting (visible or not) and intersectionRatio (how much). Call unobserve when you are done with one.

The core of all of this is one geometry question: does the element's rectangle overlap the viewport? Here is that math, runnable:

// The viewport is just a rectangle: top 0, bottom = its height
const viewport = { top: 0, bottom: 600 };

// Each element has a vertical position (top/bottom in page-scroll space)
function isIntersecting(rect, view) {
  // overlapping if the element's top is above the view's bottom
  // AND the element's bottom is below the view's top
  return rect.top < view.bottom && rect.bottom > view.top;
}

console.log(isIntersecting({ top: 100, bottom: 200 }, viewport));
// ✅ Expected output: true   (fully inside)

console.log(isIntersecting({ top: 700, bottom: 800 }, viewport));
// ✅ Expected output: false  (below the fold)

console.log(isIntersecting({ top: 550, bottom: 650 }, viewport));
// ✅ Expected output: true   (half in, half out)

2️⃣ Threshold & Intersection Ratio

The threshold option decides how much of an element must show before the callback fires: 0 means "any pixel", 1 means "fully visible", 0.5 means "half". Pass an array to get notified at several levels. Inside the callback, entry.intersectionRatio tells you the exact fraction visible.

The ratio is just the overlapping height divided by the element's height — let's compute it:

const viewport = { top: 0, bottom: 600 };

function intersectionRatio(rect, view) {
  const visibleTop = Math.max(rect.top, view.top);
  const visibleBottom = Math.min(rect.bottom, view.bottom);
  const visible = Math.max(0, visibleBottom - visibleTop);
  const height = rect.bottom - rect.top;
  return visible / height;
}

const fires = (rect, threshold) =>
  intersectionRatio(rect, viewport) >= threshold;

// Element sitting 550..650: 50px of its 100px are visible -> 0.5
const card = { top: 550, bottom: 650 };
console.log("ratio:", intersectionRatio(card, viewport));
// ✅ Expected output: ratio: 0.5

console.log("fires at threshold 0.25?", fires(card, 0.25));
// ✅ Expected output: fires at threshold 0.25? true
console.log("fires at threshold 0.75?", fires(card, 0.75));
// ✅ Expected output: fires at threshold 0.75? false

🎯 Your Turn #1 — Read isIntersecting

Replace the blank with the entry property that is true when an element is visible.

// A fake entry like the one IntersectionObserver gives you
const entry = { isIntersecting: true, intersectionRatio: 1, target: "hero" };

// 👉 replace ___ with the property that tells you it's visible
if (entry.___) {
  console.log(entry.target, "appeared!");
  // ✅ Expected output: hero appeared!
}

3️⃣ rootMargin, Lazy Loading & Infinite Scroll

rootMargin inflates the viewport box before the intersection test, so you can trigger work early. A rootMargin of "200px" means "treat elements as visible when they are still 200px below the fold" — ideal for lazy-loading an image just before it scrolls in. The same idea drives infinite scroll: observe a sentinel at the list's bottom and load the next page when it nears the screen.

💡 Tip: Always unobserve a lazy image after it loads — otherwise the observer keeps firing for an element you no longer care about.

Here is what rootMargin does to the math — it just expands the viewport's bottom edge:

const viewport = { top: 0, bottom: 600 };

function isIntersecting(rect, view, rootMarginPx = 0) {
  // rootMargin grows the box: bottom edge pushes further down
  const grown = { top: view.top - rootMarginPx, bottom: view.bottom + rootMarginPx };
  return rect.top < grown.bottom && rect.bottom > grown.top;
}

// An image sitting 700..800 — just below the real fold
const img = { top: 700, bottom: 800 };

console.log("without margin:", isIntersecting(img, viewport, 0));
// ✅ Expected output: without margin: false

console.log("with 200px margin:", isIntersecting(img, viewport, 200));
// ✅ Expected output: with 200px margin: true  (loads early!)

🎯 Your Turn #2 — Start Watching

Replace the blank with the observer method that starts watching an element.

// A mini observer model: register elements, then "scroll" past them
function makeObserver(onVisible) {
  const watched = [];
  return {
    observe: (el) => watched.push(el),
    scrollTo: (y) => watched.forEach((el) => { if (el.top <= y) onVisible(el.id); }),
  };
}

const obs = makeObserver((id) => console.log(id, "visible"));
const banner = { id: "banner", top: 300 };

// 👉 replace ___ with the method that starts watching the element
obs.___(banner);
obs.scrollTo(400); // ✅ Expected output: banner visible

🧠 Quick Recall

Predict the answer before revealing it.

...the element is fully visible (100% on screen).

Expands the viewport box by 300px top and bottom, so elements count as visible 300px early.

No per-frame polling or getBoundingClientRect; the browser does the math efficiently and only calls you on a real change.

Common Errors & Fixes

🎯 Mini Challenge — Which Cards Are On Screen?

Given a list of cards at different scroll positions and a viewport, return the IDs of the cards that are at least 50% visible — the exact decision an observer with threshold: 0.5 makes.

const viewport = { top: 0, bottom: 500 };
const cards = [
  { id: "a", top: -100, bottom: 50 },  // mostly above the fold
  { id: "b", top: 100, bottom: 200 },  // fully visible
  { id: "c", top: 450, bottom: 550 },  // half visible
  { id: "d", top: 700, bottom: 800 },  // below the fold
];

function ratio(rect, view) {
  const visible = Math.max(0, Math.min(rect.bottom, view.bottom) - Math.max(rect.top, view.top));
  return visible / (rect.bottom - rect.top);
}

const onScreen = cards
  .filter((c) => ratio(c, viewport) >= 0.5)
  .map((c) => c.id);

console.log("at least 50% visible:", onScreen);
// ✅ Expected output: at least 50% visible: [ 'b', 'c' ]

Quick Reference — IntersectionObserver

GoalCode
Createnew IntersectionObserver(cb, opts)
Watchobserver.observe(el)
Stop oneobserver.unobserve(el)
Is visible?entry.isIntersecting
Load early{ rootMargin: "200px" }

📚 Keep Learning

🎉 Lesson Complete — IntersectionObserver!

Practice quiz

What does IntersectionObserver do?

  • Watches for network requests
  • Observes attribute changes
  • Efficiently watches when an element scrolls into or out of view
  • Stores data offline

Answer: Efficiently watches when an element scrolls into or out of view. IntersectionObserver efficiently watches when an element's visibility crosses a threshold as it scrolls.

Why is IntersectionObserver better than a scroll event listener?

  • It avoids per-frame polling and getBoundingClientRect, doing the math efficiently and calling back only on real changes
  • It runs on the server
  • It can change CSS
  • It works without a viewport

Answer: It avoids per-frame polling and getBoundingClientRect, doing the math efficiently and calling back only on real changes. Scroll listeners fire constantly and force layout reads; the observer does visibility math efficiently and only calls you when something changes.

How do you start watching an element?

  • observer.watch(el)
  • observer.add(el)
  • observer.track(el)
  • observer.observe(el)

Answer: observer.observe(el). You call observer.observe(element) for each element you want to watch.

Which entry property tells you whether an element is currently visible?

  • entry.visible
  • entry.isIntersecting
  • entry.onScreen
  • entry.shown

Answer: entry.isIntersecting. entry.isIntersecting is true when the element is visible (intersecting the root).

What does a threshold of 1 mean?

  • Fire only when the element is fully (100%) visible
  • Fire when any single pixel is visible
  • Fire when half visible
  • Never fire

Answer: Fire only when the element is fully (100%) visible. threshold 1 fires only when the element is fully visible; 0 fires at any pixel; 0.5 at half.

What does entry.intersectionRatio tell you?

  • The element's color
  • The scroll speed
  • The exact fraction of the element that is visible
  • The element's width in pixels

Answer: The exact fraction of the element that is visible. intersectionRatio is the fraction of the element currently visible (visible height divided by element height).

What does rootMargin do?

  • Adds padding inside the element
  • Grows or shrinks the root's box before intersection is calculated, e.g. to trigger early
  • Sets the scroll position
  • Changes the threshold to 1

Answer: Grows or shrinks the root's box before intersection is calculated, e.g. to trigger early. rootMargin inflates (or shrinks) the viewport box before the intersection test, letting you load content early.

For lazy-loading an image, what should you do after it loads?

  • Nothing
  • Re-observe it again
  • Set threshold to 0
  • Call observer.unobserve(target) so it stops firing

Answer: Call observer.unobserve(target) so it stops firing. After a one-time target loads you should unobserve it, otherwise the observer keeps firing for an element you no longer need.

What argument does the observer callback receive?

  • A single entry
  • An array of entries (loop over them)
  • The DOM element
  • A boolean

Answer: An array of entries (loop over them). The callback receives an array of entries, so you loop over them rather than expecting one.

Which is a valid rootMargin value?

  • "200"
  • 200
  • "200px 0px"
  • { top: 200 }

Answer: "200px 0px". rootMargin uses CSS-style values with units, like '200px 0px'; a plain '200' is invalid.

Continue this course

Frequently asked questions

Why use IntersectionObserver instead of a scroll event listener?

Scroll listeners fire dozens of times a second and force you to call getBoundingClientRect on every tick, which triggers layout and tanks performance. IntersectionObserver does the visibility math off the main thread and calls you back only when an element actually crosses a threshold. It is both far more efficient and far simpler — no throttling, no manual rectangle math.

What does the threshold option control?

threshold is how much of the target must be visible before your callback fires, from 0 to 1. A threshold of 0 fires as soon as a single pixel appears; 1 fires only when the element is fully visible; 0.5 fires at half. You can also pass an array like [0, 0.25, 0.5, 1] to be notified at several visibility levels as the element scrolls through.

What is rootMargin used for?

rootMargin grows or shrinks the root's bounding box before intersection is calculated, using CSS-like values such as '200px 0px'. A positive bottom margin makes elements count as 'visible' before they truly enter the screen — perfect for lazy-loading images a bit early so they finish loading just as the user scrolls to them.

How do I lazy-load images or build infinite scroll with it?

For lazy images, observe each <img>, and when its entry.isIntersecting becomes true, swap data-src into src and unobserve it. For infinite scroll, observe a sentinel element at the bottom of the list; when it intersects, fetch the next page and the observer keeps firing as the user reaches the new bottom.