IntersectionObserver
"Is this element visible in the viewport yet?" used to require scroll listeners, getBoundingClientRect reads on every frame, and careful debouncing. The IntersectionObserver API replaces all of that with a single declarative callback. The browser does the work off the main thread and tells you when an element enters or leaves a root — the viewport by default. Lazy-loading images, infinite scroll, scroll-triggered animations and ad-impression tracking all build on it.
The minimal example
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
console.log(entry.target, entry.isIntersecting);
}
});
observer.observe(document.querySelector(".sentinel"));The callback runs whenever the intersection between a watched element and its root changes meaningfully. Each entry reports the latest state for one observed target.
The entry object
target— the observed element.isIntersecting— boolean, true when any pixel of the element is inside the root.intersectionRatio— number from 0 to 1, what fraction of the element is visible.boundingClientRect— the element's bounds.rootBounds— the root's bounds.time— DOMHighResTimeStamp of the report.
Options: root, rootMargin, threshold
new IntersectionObserver(callback, {
root: null, // null → viewport. Pass a scrollable ancestor otherwise.
rootMargin: "0px", // CSS-style margin around the root. "200px 0px" extends vertically.
threshold: 0, // ratio (0..1) or array of ratios that trigger the callback.
});root: nullis the viewport. Setting it to an element lets you observe visibility inside a scrolling container.rootMargin: "200px"triggers the callback before the element technically enters — perfect for "prefetch when within 200 px of the viewport".threshold: [0, 0.25, 0.5, 0.75, 1]calls back at each crossing. Use a single value for binary visible/hidden, an array for fade-in animations.
Lazy-loading images
Modern browsers also support <img loading="lazy">, but an observer gives you full control — you can lazy-load anything, not just images, and tune the prefetch distance.
lazy.js
const lazy = new IntersectionObserver((entries, obs) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const img = entry.target;
img.src = img.dataset.src;
obs.unobserve(img); // load each image only once
}
}, { rootMargin: "200px" });
document.querySelectorAll("img[data-src]").forEach((img) => lazy.observe(img));Infinite scroll
Place a small sentinel element at the bottom of the list. When it scrolls into view, fetch the next page and append.
const sentinel = document.querySelector(".sentinel");
let page = 1;
let loading = false;
const io = new IntersectionObserver(async (entries) => {
if (!entries[0].isIntersecting || loading) return;
loading = true;
const items = await fetch(`/items?page=${++page}`).then((r) => r.json());
appendItems(items);
loading = false;
});
io.observe(sentinel);Scroll-triggered animations
const reveal = new IntersectionObserver((entries, obs) => {
for (const entry of entries) {
if (entry.isIntersecting) {
entry.target.classList.add("in-view");
obs.unobserve(entry.target);
}
}
}, { threshold: 0.15 });
document.querySelectorAll(".reveal").forEach((el) => reveal.observe(el));Pair with a CSS transition on .in-view — no JS animation loop required.
Tracking visible time (analytics)
An ad-impression tracker often requires "50% visible for at least 1 second". Combine threshold: 0.5 with a timer.
const timers = new WeakMap();
const io = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.intersectionRatio >= 0.5) {
timers.set(entry.target, setTimeout(() => report(entry.target), 1000));
} else {
clearTimeout(timers.get(entry.target));
}
}
}, { threshold: [0, 0.5] });Cleaning up
observer.unobserve(el); // stop watching one element observer.disconnect(); // stop watching everything
The browser also frees the observer when the watched elements are garbage-collected — but explicit disconnection is the safe habit for SPAs.
How it's better than scroll listeners
The callback only runs when intersection changes, not on every scroll tick.
No layout reads — the browser already knows the bounds.
Throttling is built in: you cannot get a stampede of events.
Works for scroll containers and the viewport with the same API.