IntersectionObserver in Practice — Detect Element Visibility Without Scroll Listeners
The era of polling scroll events and calling getBoundingClientRect on every frame is over. IntersectionObserver lets the browser tell you when an element enters view — efficiently, and with way less code.

Why scroll-event polling gets slow
"Fade this in when it scrolls into view." "Lazy-load these images as the user gets close." Common requirements, both of them. The old way to handle these was to attach a scroll listener on window, then call getBoundingClientRect() on every element to figure out whether it was on screen. It works, but it's surprisingly heavy.
Scroll events fire dozens of times per second. getBoundingClientRect() forces the browser to recalculate layout each time it's called. Stack those two together with even a moderate number of elements and you'll feel the jank. You can throttle, you can use requestAnimationFrame, but the fundamental problem stays: you're doing visibility math on the main thread, every single frame the user scrolls.
That's where IntersectionObserver comes in. It's a browser API that watches whether an element intersects with the viewport (or another element) and tells you about changes — and the actual checking happens off the main thread. The result is something that's both faster and easier to write than the scroll-listener approach it replaces.
The minimum setup: observe, then react
The shape of the API is small. Create an observer, hand it the elements you want to watch, and write a callback that runs whenever their intersection state changes. Three steps, that's it.
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
}
});
});
/* Register the elements you want to watch */
document.querySelectorAll('.fade-in').forEach((el) => {
observer.observe(el);
});
The callback receives entries, an array of elements whose intersection state just changed. If isIntersecting is true, the element is now in view; if it's false, it just left. That single boolean is enough to power "add a class when this thing scrolls into view."
Scroll inside the box below. The green cards fade in the moment they enter the visible area.
The demo above quietly uses two options — root and threshold — that we'll unpack in the next section.
The three options you'll actually use: root, rootMargin, threshold
The constructor takes an options object as its second argument. In day-to-day work, these three options cover almost everything.
const observer = new IntersectionObserver(callback, {
/* What to measure intersection against (defaults to the viewport) */
root: document.querySelector('.scroll-area'),
/* Expand or shrink the detection area, CSS margin syntax */
rootMargin: '0px 0px -100px 0px',
/* How much overlap counts as "intersecting" (0–1, or an array) */
threshold: 0.3
});
root is the element that intersection is measured against. Leave it out and you get the viewport. Pass a scroll container, and the observer tracks visibility relative to that container's scroll. The fade-in demo earlier used the box itself as the root because the scrolling happened inside it.
rootMargin grows or shrinks the detection box. '0px 0px -100px 0px' shrinks the bottom edge inward by 100px, so the element only counts as visible after it's already 100px above the bottom of the viewport. It's the easiest way to make triggers fire earlier or later than the literal edge.
threshold sets how much of the element needs to overlap the root before it's considered intersecting. 0 means "any pixel," 1 means "fully inside," 0.5 means "half visible." Pass an array like [0, 0.5, 1] to fire the callback at multiple points.
Pattern 1: Image lazy loading
Loading images only as they approach the viewport keeps initial pages light and skips downloads users never see. The native loading="lazy" attribute is great for plain cases, but if you want custom behavior — fade in on load, swap from a low-res placeholder, run analytics — IntersectionObserver gives you full control.
const lazyIo = new IntersectionObserver((entries, obs) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const img = entry.target;
img.src = img.dataset.src;
img.classList.add('is-loaded');
/* Once it's loaded, stop watching it */
obs.unobserve(img);
});
}, { rootMargin: '200px' });
document.querySelectorAll('img[data-src]').forEach((img) => {
lazyIo.observe(img);
});
Two details to call out. First, rootMargin: '200px'. Starting the load 200px before the image hits the viewport means it's usually done by the time the user scrolls to it. Triggering the load exactly at the edge looks worse — users see a blank gap that fills in too late.
Second, unobserve(). Once an image is loaded, there's no point in watching it anymore. Unobserving keeps the observer's set small and your code's intent clear: this is a one-shot trigger. Any time the rule is "fire once and we're done," reach for unobserve.
The gray placeholders below switch to a "loaded" state as they approach the viewport. Once flipped, they're unobserved.
Pattern 2: Infinite scroll
Put a sentinel element at the end of your list, and load the next page when it scrolls into view. It's the canonical infinite-scroll pattern, and it pairs perfectly with IntersectionObserver.
const sentinel = document.querySelector('.sentinel');
const io = new IntersectionObserver(async (entries) => {
if (!entries[0].isIntersecting) return;
const items = await fetchNextPage();
renderItems(items);
if (isLastPage()) io.unobserve(sentinel);
});
io.observe(sentinel);
The clever part is observing just one element — the sentinel at the end — instead of every list item. The structure stays simple: when the bottom marker is about to be visible, fetch more. Once you've loaded the last page, unobserve and you're done.
Scroll to the bottom and more items appear. The list caps out at 20 items, after which the sentinel is unobserved.
Pattern 3: Scroll-spy table of contents
Long articles often want a table of contents that highlights the section the reader is currently in. IntersectionObserver makes this almost trivial.
const sections = document.querySelectorAll('section[id]');
const navLinks = document.querySelectorAll('.toc a');
const spy = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const id = entry.target.id;
navLinks.forEach((a) => {
a.classList.toggle(
'is-active',
a.getAttribute('href') === '#' + id
);
});
});
}, { rootMargin: '-40% 0px -50% 0px' });
sections.forEach((s) => spy.observe(s));
The trick is in the rootMargin. '-40% 0px -50% 0px' shrinks the detection area to a horizontal band roughly in the middle of the viewport. Only sections that intersect that band get marked active. Unlike fade-in cases where you want to detect "anywhere on screen," scroll-spy works best when you narrow the focus down to "what the reader is actually looking at."
Scroll the right-hand content and watch the active item in the left-hand nav update.
Section 1
Scroll to the next section and the highlighted nav link follows along.
Section 2
The trick is narrowing the detection area to a band near the middle of the viewport with rootMargin.
Section 3
What used to be a heavy scroll listener is now just a few lines of declarative code.
Section 4
Made it this far? The last item in the nav should now be highlighted.
Production checklist
Before shipping, a few things worth keeping in mind. Some of these are spec quirks, others are just operational habits that save you from subtle bugs.
First, the initial firing. The moment you call observe(), the callback fires once with the element's current intersection state. That's why elements already on screen at page load get their fade-in immediately. If your logic specifically wants "only when something newly enters view," account for that initial event.
Second, unobserve when you're done. Fade-ins and lazy loading are one-shot — once the trigger fires, you don't need to watch the element anymore. Calling unobserve() keeps the observer's working set small. With lots of elements, it adds up.
Third, in component frameworks like React or Vue, always call disconnect() on unmount. Disconnect releases all observed elements at once and tears down the observer. Forgetting this is a classic memory-leak source in long-lived SPAs.
/* React useEffect example */
useEffect(() => {
const io = new IntersectionObserver(callback, options);
io.observe(ref.current);
return () => io.disconnect();
}, []);
Fourth, browser support. IntersectionObserver has shipped in every modern browser for years now. Unless your project still has to run on legacy IE — which is rare in 2026 — you can use it without a polyfill, including the version that targets a specific scroll container via root.
Takeaways
- IntersectionObserver detects when an element intersects the viewport (or another element) and is the modern replacement for scroll listeners plus getBoundingClientRect.
- The setup is three steps: create the observer, call observe() on each element, and check isIntersecting in the callback.
- The three options that matter in practice are root (the reference element), rootMargin (adjust the detection area), and threshold (how much overlap counts).
- For fade-ins, a threshold of 0.1 to 0.3 usually feels right — zero fires too early.
- For lazy loading, expand rootMargin so loading starts before the element is visible. Call unobserve() after the load completes.
- Infinite scroll works cleanly with a single sentinel element at the end of the list — no need to observe every item.
- Scroll spies work best when rootMargin shrinks the detection area to a band near the middle of the viewport, like '-40% 0px -50% 0px'.
- In components, always call disconnect() on unmount to avoid memory leaks.