IntersectionObserver

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.

Learn IntersectionObserver in our free JavaScript course — an interactive lesson with runnable examples, a practice exercise and a quick reference.

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.

💡 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.

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 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:

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

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:

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

...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.

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.

Up next: MutationObserver — react to DOM changes. 🔬

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.