The Intersection Observer API
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 serves as 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 });
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.
// 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'
});
threshold
A number or array specifying what percentage of visibility triggers the callback. A single value like 0.1 means the callback fires when 10% of the target is visible. An array triggers at multiple thresholds.
// 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. This improves initial page load time by deferring image requests until necessary.
<img data-src="large-image.jpg" alt="Lazy loaded" class="lazy">
// 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 seamless 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 at multiple visibility levels. This is useful for implementing scroll progress 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
Intersection Observer makes infinite scrolling straightforward:
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, use a polyfill from npm:
npm install intersection-observer
import 'intersection-observer';
The polyfill falls back to scroll event calculations for unsupported browsers, though with worse performance.
Common Pitfalls
Forgetting to unobserve
Always call unobserve() once you’ve handled an element, otherwise the callback continues firing on every scroll:
// Good: stop observing after handling
if (entry.isIntersecting) {
loadImage(entry.target);
observer.unobserve(entry.target); // Stop watching this element
}
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