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.

