The Intersection Observer API lets you detect when elements enter or leave the viewport — without polling, without scroll event listeners, and without expensive getBoundingClientRect() calls. It's the modern solution for lazy loading, infinite scroll, animation triggers, and ad viewability measurement. Firefox has full support.
Why Intersection Observer
The old approach to detecting element visibility:
// BAD: forces synchronous layout on every scroll event
window.addEventListener('scroll', () => {
const rect = element.getBoundingClientRect();
if (rect.top < window.innerHeight) {
loadImage();
}
});
Problems:
-
getBoundingClientRect()forces a synchronous layout (expensive) - Scroll events fire rapidly (60+ times/second on smooth scroll)
- Main thread blocking causes jank
Intersection Observer:
- Runs asynchronously, not on the main thread
- Callbacks only fire when intersection changes (not on every scroll)
- Batches observations
- ~10-100x less CPU usage
Basic Intersection Observer
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// Element entered viewport
loadContent(entry.target);
observer.unobserve(entry.target); // Stop watching after first load
}
});
});
// Start observing elements
document.querySelectorAll('.lazy-image').forEach(img => {
observer.observe(img);
});
Options: threshold and rootMargin
const observer = new IntersectionObserver(callback, {
// threshold: percentage visible that triggers callback
threshold: 0, // trigger when any pixel is visible (default)
threshold: 0.5, // trigger when 50% is visible
threshold: 1.0, // trigger only when fully visible
threshold: [0, 0.25, 0.5, 0.75, 1.0], // trigger at each percentage
// rootMargin: expand/contract the observation area
rootMargin: '0px', // default (exactly viewport)
rootMargin: '200px', // start loading 200px before entering viewport
rootMargin: '-20px', // only trigger when element is 20px inside viewport
rootMargin: '0px 0px -100px 0px', // top right bottom left
// root: default is the viewport
root: document.getElementById('scroll-container'), // or any scrollable element
});
Lazy Loading Images
<img data-src="/photo.jpg" class="lazy" alt="Photo" loading="lazy">
// IntersectionObserver approach (works everywhere, more control)
const imageObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.classList.remove('lazy');
imageObserver.unobserve(img);
}
});
}, { rootMargin: '300px' }); // Start loading 300px before viewport
document.querySelectorAll('img.lazy').forEach(img => imageObserver.observe(img));
Alternative: the loading="lazy" HTML attribute is simpler for images and <iframe> elements. Firefox supports it natively. The IntersectionObserver approach gives you more control (custom loading indicator, threshold, etc.).
Infinite Scroll
// Add a sentinel element at the bottom of the list
const sentinel = document.getElementById('list-sentinel');
const sentinelObserver = new IntersectionObserver(async (entries) => {
if (entries[0].isIntersecting) {
await loadMoreItems();
}
}, { rootMargin: '100px' }); // Load 100px before reaching the bottom
sentinelObserver.observe(sentinel);
async function loadMoreItems() {
const newItems = await fetchNextPage();
renderItems(newItems);
// sentinel is still being observed — will trigger again when it comes back into view
}
Scroll-Triggered Animations
const animationObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('animate-in');
}
// Optionally remove class to re-animate on scroll up:
// else { entry.target.classList.remove('animate-in'); }
});
}, { threshold: 0.15 }); // Trigger when 15% visible
document.querySelectorAll('.animate-on-scroll').forEach(el => {
animationObserver.observe(el);
});
.animate-on-scroll {
opacity: 0;
transform: translateY(30px);
transition: opacity 0.5s, transform 0.5s;
}
.animate-on-scroll.animate-in {
opacity: 1;
transform: translateY(0);
}
Reading Progress Indicator
const article = document.getElementById('article');
const progressBar = document.getElementById('progress-bar');
const progressObserver = new IntersectionObserver((entries) => {
const articleRect = article.getBoundingClientRect();
const progress = Math.min(
1,
Math.max(0, (window.innerHeight - articleRect.top) / articleRect.height)
);
progressBar.style.width = (progress * 100) + '%';
}, { threshold: Array.from({ length: 101 }, (_, i) => i / 100) }); // 100 thresholds
progressObserver.observe(article);
Table of Contents Active Link
const headings = document.querySelectorAll('h2, h3');
const tocLinks = document.querySelectorAll('.toc a');
const headingObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const id = entry.target.id;
const tocLink = document.querySelector(`.toc a[href="#${id}"]`);
if (entry.isIntersecting) {
tocLinks.forEach(l => l.classList.remove('active'));
tocLink?.classList.add('active');
}
});
}, { rootMargin: '-100px 0px -66% 0px' }); // Focus on top third of viewport
headings.forEach(h => headingObserver.observe(h));
IntersectionObserverEntry Properties
In the callback, each entry has:
entry.isIntersecting // boolean: visible now?
entry.intersectionRatio // 0-1: how much is visible
entry.boundingClientRect // element's position
entry.intersectionRect // visible portion's position
entry.rootBounds // viewport/root bounds
entry.target // the DOM element
entry.time // DOMHighResTimeStamp when the change was detected
Cleanup
Always disconnect when no longer needed:
// Stop observing a specific element
observer.unobserve(element);
// Disconnect all observations (clean up when component unmounts)
observer.disconnect();
In React:
useEffect(() => {
const observer = new IntersectionObserver(callback);
observer.observe(elementRef.current);
return () => observer.disconnect(); // cleanup
}, []);
Firefox DevTools Visualization
There's no dedicated Intersection Observer panel in DevTools, but you can debug it:
- Add
console.log(entry)in the callback - Open DevTools Console
- Scroll the page — you'll see entries logged with full IntersectionObserverEntry data
Check entry.intersectionRatio and entry.rootBounds to verify your thresholds are triggering correctly.
Top comments (0)