A production-ready React hook that wraps the Intersection Observer API, returning a ref and isIntersecting boolean with configurable threshold and rootMargin options.
import { useEffect, useRef, useState, useCallback, type RefObject } from 'react';
interface UseIntersectionObserverOptions {
threshold?: number | number[];
rootMargin?: string;
root?: Element | Document | null;
freezeOnceVisible?: boolean;
}
interface UseIntersectionObserverReturn<T extends Element> {
ref: RefObject<T | null>;
isIntersecting: boolean;
entry: IntersectionObserverEntry | null;
}
function useIntersectionObserver<T extends Element = HTMLDivElement>(
options: UseIntersectionObserverOptions = {}
): UseIntersectionObserverReturn<T> {
const {
threshold = 0,
rootMargin = '0px',
root = null,
freezeOnceVisible = false,
} = options;
const ref = useRef<T | null>(null);
const [isIntersecting, setIsIntersecting] = useState(false);
const [entry, setEntry] = useState<IntersectionObserverEntry | null>(null);
const frozen = useRef(false);
const updateEntry = useCallback(
([newEntry]: IntersectionObserverEntry[]): void => {
if (frozen.current) return;
setEntry(newEntry);
setIsIntersecting(newEntry.isIntersecting);
if (freezeOnceVisible && newEntry.isIntersecting) {
frozen.current = true;
}
},
[freezeOnceVisible]
);
useEffect(() => {
const node = ref.current;
if (!node) return;
if (typeof IntersectionObserver === 'undefined') {
setIsIntersecting(true);
return;
}
const observerOptions: IntersectionObserverInit = {
threshold,
rootMargin,
root,
};
const observer = new IntersectionObserver(updateEntry, observerOptions);
observer.observe(node);
return () => {
observer.disconnect();
};
}, [threshold, rootMargin, root, updateEntry]);
return { ref, isIntersecting, entry };
}
export { useIntersectionObserver };
export type { UseIntersectionObserverOptions, UseIntersectionObserverReturn };
// ============================================
// Example: Lazy Loading Image Component
// ============================================
import React from 'react';
interface LazyImageProps {
src: string;
alt: string;
placeholder?: string;
className?: string;
rootMargin?: string;
threshold?: number;
}
function LazyImage({
src,
alt,
placeholder = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300"%3E%3Crect fill="%23e0e0e0" width="400" height="300"/%3E%3C/svg%3E',
className = '',
rootMargin = '200px',
threshold = 0.1,
}: LazyImageProps): React.ReactElement {
const { ref, isIntersecting } = useIntersectionObserver<HTMLImageElement>({
threshold,
rootMargin,
freezeOnceVisible: true,
});
const [isLoaded, setIsLoaded] = useState(false);
const [hasError, setHasError] = useState(false);
const handleLoad = useCallback(() => {
setIsLoaded(true);
}, []);
const handleError = useCallback(() => {
setHasError(true);
}, []);
const imageSrc = isIntersecting && !hasError ? src : placeholder;
return (
<div style={{ position: 'relative', overflow: 'hidden' }}>
<img
ref={ref}
src={imageSrc}
alt={alt}
className={className}
onLoad={handleLoad}
onError={handleError}
style={{
transition: 'opacity 0.3s ease-in-out',
opacity: isLoaded || !isIntersecting ? 1 : 0.5,
width: '100%',
height: 'auto',
display: 'block',
}}
/>
{isIntersecting && !isLoaded && !hasError && (
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
fontSize: '14px',
color: '#666',
}}
>
Loading...
</div>
)}
{hasError && (
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
fontSize: '14px',
color: '#d32f2f',
}}
>
Failed to load image
</div>
)}
</div>
);
}
export { LazyImage };
export type { LazyImageProps };
// ============================================
// Usage Example in App Component
// ============================================
function ImageGallery(): React.ReactElement {
const images = [
{ src: 'https://picsum.photos/800/600?random=1', alt: 'Random image 1' },
{ src: 'https://picsum.photos/800/600?random=2', alt: 'Random image 2' },
{ src: 'https://picsum.photos/800/600?random=3', alt: 'Random image 3' },
{ src: 'https://picsum.photos/800/600?random=4', alt: 'Random image 4' },
{ src: 'https://picsum.photos/800/600?random=5', alt: 'Random image 5' },
];
return (
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '20px' }}>
<h1>Lazy Loading Image Gallery</h1>
<p>Scroll down to see images load as they enter the viewport.</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '400px' }}>
{images.map((image, index) => (
<LazyImage
key={index}
src={image.src}
alt={image.alt}
rootMargin="100px"
threshold={0.1}
/>
))}
</div>
</div>
);
}
export default ImageGallery;The useIntersectionObserver hook provides a clean abstraction over the browser's Intersection Observer API, which is essential for implementing performance-optimized features like lazy loading, infinite scrolling, and scroll-triggered animations. The hook accepts configuration options including threshold (how much of the element must be visible), rootMargin (extends or shrinks the viewport for intersection calculations), and freezeOnceVisible (stops observing after first intersection).
The implementation uses a ref to track the target element and maintains state for both the boolean isIntersecting flag and the full IntersectionObserverEntry object for advanced use cases. The freezeOnceVisible option is particularly important for lazy loading scenarios where you want to load content once and never unload it, even if the user scrolls away. This prevents unnecessary re-renders and API calls.
A key design decision is the graceful degradation when IntersectionObserver isn't available (older browsers). In this case, we default isIntersecting to true, ensuring content still loads rather than remaining hidden forever. The effect cleanup properly disconnects the observer to prevent memory leaks, and the dependency array ensures the observer is recreated only when options change.
The LazyImage component demonstrates practical usage with proper loading and error states. The rootMargin of '200px' starts loading images before they enter the viewport, creating a smoother user experience. The component shows a placeholder SVG until the real image loads, then fades it in with a CSS transition for a polished feel.
Use this hook for lazy loading images, implementing infinite scroll pagination, triggering animations when elements become visible, or tracking which sections a user has viewed for analytics. Avoid using it when you need pixel-perfect scroll position tracking (use scroll events instead) or when dealing with elements that frequently enter and exit the viewport in rapid succession, as the observer callbacks can accumulate.