The Intersection Observer API for Viewport Detection
The Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or the viewport. This enables you to implement lazy loading, infinite scroll, and scroll-triggered animations without manually calculating element positions.
Before this API arrived, developers relied on attaching scroll event listeners and calling getBoundingClientRect() on every scroll event. This approach caused performance problems because scroll events fire rapidly, and layout calculations force the browser to recalculate element positions repeatedly.
How intersection observer works
Instead of attaching scroll event listeners that fire constantly as users scroll, Intersection Observer lets you register a callback that runs when an element crosses a specified threshold.
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
console.log(entry.isIntersecting);
});
}, {
root: null,
rootMargin: '0px',
threshold: 0.1
});
observer.observe(document.querySelector('.target'));
The callback receives an array of IntersectionObserverEntry objects. Each entry contains information about the intersection state, including:
entry.isIntersecting: true if the target is currently visibleentry.intersectionRatio: the percentage of visibility (0 to 1)entry.target: the element being observedentry.time: when the intersection occurred
Configuration options
The second argument to the IntersectionObserver constructor accepts an options object with three properties:
root
The ancestor element that is the viewport. Use null for the browser viewport, or pass a specific element to observe intersection with that element’s bounds.
// Observe intersection with a specific container
const container = document.querySelector('.scroll-container');
const observer = new IntersectionObserver(callback, { root: container });
Setting root: null watches the viewport, which is the most common choice. When you set root to a scrollable container, intersection is measured against that element’s bounds instead, which is useful for chat widgets, custom scroll areas, and modals.
rootMargin
Expands or contracts the root bounding box. Accepts pixel values or percentages. Negative values shrink the box, making the intersection area smaller, while positive values expand it, triggering callbacks before the element actually enters the viewport:
// Trigger when element is 100px away from viewport edge
const observer = new IntersectionObserver(callback, {
rootMargin: '100px'
});
// Asymmetric margins: 50px top, 200px bottom
const observer2 = new IntersectionObserver(callback, {
rootMargin: '50px 0px 200px 0px'
});
A single rootMargin string can specify different values for each side, in the same order as CSS margin: top, right, bottom, left. A common pattern is to set a generous bottom margin for lazy loading, so images start fetching before the user scrolls to them.
threshold
A number or array specifying what percentage of visibility triggers the callback. A single value like 0.1 fires when 10% of the target is visible. An array fires at multiple percentages, which is useful for progress tracking:
// Single threshold
const observer = new IntersectionObserver(callback, { threshold: 0.5 });
// Multiple thresholds - fires at 0%, 25%, 50%, 75%, and 100%
const observer2 = new IntersectionObserver(callback, {
threshold: [0, 0.25, 0.5, 0.75, 1]
});
Practical example: lazy loading images
A common use case is lazy loading images only when they enter the viewport. The technique defers image requests until the user scrolls near them, which improves initial page load time. The HTML starts with a data-src attribute instead of src, so the browser makes no network request on page load:
<img data-src="large-image.jpg" alt="Lazy loaded" class="lazy">
The observer watches every image with a data-src attribute. When an image nears the viewport — helped by rootMargin: '100px' for preloading — the callback moves data-src into src, triggering the browser to fetch the image. After the swap, unobserve stops watching that image so the callback doesn’t fire on it again:
// Select all images with data-src (not src)
const lazyImages = document.querySelectorAll('img[data-src]');
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.removeAttribute('data-src');
observer.unobserve(img);
}
});
}, {
rootMargin: '100px',
threshold: 0
});
lazyImages.forEach(img => imageObserver.observe(img));
This pattern loads images just before they become visible. The rootMargin: '100px' starts loading when the image is 100px away from entering the viewport, making the transition smooth for users.
Detecting when an element leaves
The isIntersecting property tells you whether the target is currently intersecting, making it easy to detect both entry and exit:
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
} else {
entry.target.classList.remove('visible');
}
});
}, { threshold: 0.5 });
document.querySelectorAll('.fade-in').forEach(el => observer.observe(el));
This pattern is useful for implementing scroll-triggered animations where elements fade in when they enter the viewport.
Using multiple thresholds for progress tracking
Pass an array to threshold to trigger callbacks at several visibility percentages. Each entry in the array produces a callback when the target’s intersection ratio crosses that value, which is ideal for progress bars and scroll-position indicators:
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const ratio = Math.round(entry.intersectionRatio * 100);
console.log(`Visibility: ${ratio}%`);
// Update progress bar based on visibility
const progressBar = document.querySelector('.progress');
progressBar.style.width = `${ratio}%`;
});
}, {
threshold: [0, 0.25, 0.5, 0.75, 1]
});
Infinite scroll example
Place a sentinel element (like a loader spinner) at the bottom of the content and observe it. When the sentinel enters the viewport, fetch the next page of items and append them. The rootMargin: '200px' fires the request before the user reaches the bottom, hiding network latency:
const loader = document.querySelector('.loader');
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadMoreItems();
}
}, { rootMargin: '200px' });
observer.observe(loader);
async function loadMoreItems() {
const items = await fetch('/api/items?page=' + page);
renderItems(items);
page++;
}
The rootMargin: '200px' triggers the load before the user actually reaches the loader element, creating a smoother experience.
Cleaning up observers
Always disconnect observers when they’re no longer needed to prevent memory leaks. This is especially important in single-page applications:
// When component unmounts or element is removed
observer.disconnect();
// Or stop observing a specific element while continuing to observe others
observer.unobserve(element);
Browser Support
Intersection Observer is supported in all modern browsers, including Chrome, Firefox, Safari, and Edge. For older browsers, install a polyfill that falls back to scroll event calculations:
npm install intersection-observer
Once installed, import the polyfill at the entry point of your application. It patches the global IntersectionObserver constructor so the rest of your code can use the native API without conditional checks:
import 'intersection-observer';
The polyfill falls back to scroll event calculations for unsupported browsers, though with worse performance.
Common Pitfalls
Forgetting to unobserve
Once the observer fires for an element and you have handled it (loaded the image, triggered the animation, logged the event), stop observing that element. Otherwise the callback keeps firing on every scroll event for a DOM node that no longer needs attention:
// Good: stop observing after handling
if (entry.isIntersecting) {
loadImage(entry.target);
observer.unobserve(entry.target); // Stop watching this element
}
Failing to call unobserve() or disconnect() is the most common source of Intersection Observer performance problems. The callback overhead per element may seem small, but it adds up quickly on pages with many observed targets.
Picking thresholds
Thresholds should match the moment you need an update. For lazy loading, a small threshold or zero is usually enough. For scroll progress, use several thresholds so the callback fires at useful intervals rather than on every pixel. The right choice depends on whether you care about visibility, timing, or progress, and the API lets you tune each case without custom scroll math.
Root margins for smoother UX
rootMargin is often the difference between a smooth experience and a late one. A positive bottom margin starts work before an item enters the screen, which is helpful for images and data fetches. A negative margin tightens the trigger zone when you want a stricter signal. Think of it as a buffer around the viewport, not just a technical knob.
Keep observers small
Use one observer per behavior, not one observer per element. Reusing a single instance keeps callback overhead low and makes cleanup easier. If an element only needs a temporary watch, unobserve it as soon as the state changes. That way the observer stays focused on active targets instead of spending time on nodes that no longer matter.
When scroll events still help
Intersection Observer is great for visibility, but it is not a replacement for every scroll-related task. If you need exact scroll position, sticky header math, or pixel-by-pixel parallax, a scroll listener may still be the clearer tool. Use the observer when you care about entering, leaving, or threshold changes; use scroll events when you need continuous position data.
Observer lifecycle planning
Good observer code usually starts and ends with a clear interaction. Set it up when the feature mounts, keep the callback small, and tear it down when the element is no longer visible. That rhythm keeps the page from accumulating watchers and makes it easier to read the code later, because setup and cleanup stay close together.
Matching the observer to the job
Use one observer for one behavior. If one part of the page loads media, another tracks progress, and a third handles sticky reveals, keep those watchers separate so each callback stays simple. That makes it easier to change thresholds without affecting unrelated UI. Shared observers are fine when the behavior is the same; mixed responsibilities tend to get noisy quickly.
Exit paths matter too
A good observer plan includes the moment a target stops mattering. Unobserve items after they load, disconnect an observer when the feature disappears, and reuse the same instance only while the page actually needs it. That discipline keeps invisible work from piling up and makes the code easier to scan later.
Using the wrong root
Remember that root: null uses the viewport, not the document. If you want to observe intersection with a scrollable container, pass that container as the root:
const observer = new IntersectionObserver(callback, {
root: document.querySelector('.scroll-container')
});
Threshold confusion
A threshold of 0 triggers as soon as any part of the element is visible. A threshold of 1 requires the entire element to be visible. For most use cases, a threshold between 0.1 and 0.5 works well.
See Also
- MDN: Resize Observer API: observe element size changes
- MDN: Web Crypto API: cryptographic operations in the browser
- Working with the DOM: DOM manipulation basics
- Browser Storage APIs: localStorage, sessionStorage, and IndexedDB