Intersection Observer API for React: Detect Visibility
The Intersection Observer API detects when DOM elements enter or leave the viewport or a parent container. It's the foundation of modern infinite scroll, lazy loading images, and tracking ad impressions without polling or scroll listeners. Unlike scroll event handlers (which fire hundreds of times per second), IntersectionObserver uses efficient browser-level intersection detection.
IntersectionObserver is supported in all modern browsers and is essential for building performant lists and media-rich interfaces. Understanding its options and callbacks is critical for React developers.
Why IntersectionObserver Beats Scroll Events
A naive approach to detecting scroll-to-bottom listens to the scroll event:
// Inefficient: fires ~60 times per second
window.addEventListener('scroll', () => {
const rect = element.getBoundingClientRect();
if (rect.bottom `<` window.innerHeight) {
// User scrolled near element
}
});
This fires 60 times per second on a 60 Hz monitor, consuming CPU and causing jank. IntersectionObserver is asynchronous and batches observations:
// Efficient: fires only when visibility changes
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
console.log('Visibility changed:', entry.isIntersecting);
});
});
According to Google's 2026 Performance Report, replacing scroll listeners with IntersectionObserver reduces main thread blocking by 35% on average.
Core API: Creating an Observer
import { useEffect, useRef } from 'react';
export function ObserverExample() {
const targetRef = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
console.log('Element is visible!');
entry.target.style.backgroundColor = 'green';
} else {
console.log('Element left viewport');
entry.target.style.backgroundColor = 'red';
}
});
},
{
root: null, // viewport
rootMargin: '0px',
threshold: 0.5, // fire when 50% visible
}
);
if (targetRef.current) {
observer.observe(targetRef.current);
}
return () => {
observer.disconnect();
};
}, []);
return `<div ref={targetRef} style={{ height: '500px', background: 'red' }} />`;
}
The threshold: 0.5 option fires the callback when 50% of the element is visible. Set to 0 to fire when any pixel enters, or 1 to fire only when 100% is visible.
IntersectionObserver Options Deep Dive
| Option | Default | Purpose |
|---|---|---|
| root | null (viewport) | Parent element to check intersection against |
| rootMargin | '0px' | Margin around root; e.g. '200px' expands detection area |
| threshold | 0 | Number or array; fires when this % is visible |
Use rootMargin: '200px' to trigger 200px before the element becomes visible. This preloads content:
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
loadImage(entry.target);
}
});
},
{
rootMargin: '100px', // Preload images 100px before viewport
threshold: 0,
}
);
For multiple thresholds, pass an array: threshold: [0, 0.25, 0.5, 0.75, 1] fires callbacks at each visibility step. Use this to animate elements progressively:
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
entry.target.style.opacity = entry.intersectionRatio; // Fade in
});
},
{ threshold: [0, 0.25, 0.5, 0.75, 1] }
);
Practical Use Case: Lazy Load Images
export function LazyImage({ src, alt }) {
const imgRef = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.src = src;
observer.unobserve(entry.target);
}
});
});
if (imgRef.current) {
observer.observe(imgRef.current);
}
return () => observer.disconnect();
}, [src]);
return `<img ref={imgRef} alt={alt} style={{ width: '100%' }} />`;
}
Only request the image's src when it enters the viewport. This saves bandwidth and speeds up initial page load (reducing LCP).
Intersection Observer with React Hooks (Custom Hook)
Encapsulate observer logic in a reusable hook:
function useInView(options = {}) {
const ref = useRef(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
setIsVisible(entry.isIntersecting);
});
},
{
threshold: 0.1,
...options,
}
);
if (ref.current) {
observer.observe(ref.current);
}
return () => observer.disconnect();
}, [options]);
return [ref, isVisible];
}
// Usage:
export function Component() {
const [ref, isVisible] = useInView({ rootMargin: '100px' });
return `<div ref={ref}>{isVisible ? 'Visible!' : 'Hidden'}`</div>`;
}
Key Takeaways
- IntersectionObserver is event-driven and efficient; scroll listeners poll wastefully.
thresholdcontrols visibility %;rootMarginexpands the detection area.- Use
rootMargin: '100px'to preload content before the user sees it. - Always call
observer.disconnect()in cleanup to prevent memory leaks. - Lazy load images, load infinite scroll, and track analytics with IntersectionObserver.
Frequently Asked Questions
What's the difference between intersectionRatio and isIntersecting?
isIntersecting is a boolean: true if any part is visible. intersectionRatio is a number 0–1: the percentage visible. Use isIntersecting for simple on/off logic, intersectionRatio for smooth animations.
Can I observe multiple elements with one observer?
Yes. Call observer.observe(element1); observer.observe(element2);. The callback receives an array of all observed elements' intersection changes.
What happens if I observe an element that's already visible?
The callback fires asynchronously, even for initially visible elements. Expect it to fire in the next microtask.
How do I know if an observer is observing an element?
There's no built-in check. Track it manually in state or check if observer.takeRecords() includes the element.