Detect when an element enters or leaves the viewport using the Intersection Observer API.
import { useEffect, useRef, useState, RefObject } from 'react';
interface UseIntersectionObserverOptions extends IntersectionObserverInit {
triggerOnce?: boolean;
}
interface UseIntersectionObserverReturn {
ref: RefObject<HTMLElement>;
isIntersecting: boolean;
entry: IntersectionObserverEntry | null;
}
export function useIntersectionObserver(
options: UseIntersectionObserverOptions = {}
): UseIntersectionObserverReturn {
const { triggerOnce = false, ...observerOptions } = options;
const [isIntersecting, setIsIntersecting] = useState(false);
const [entry, setEntry] = useState<IntersectionObserverEntry | null>(null);
const elementRef = useRef<HTMLElement>(null);
useEffect(() => {
const element = elementRef.current;
if (!element) return;
const observer = new IntersectionObserver(([entry]) => {
setIsIntersecting(entry.isIntersecting);
setEntry(entry);
if (triggerOnce && entry.isIntersecting) {
observer.disconnect();
}
}, observerOptions);
observer.observe(element);
return () => {
observer.disconnect();
};
}, [triggerOnce, observerOptions.root, observerOptions.rootMargin, observerOptions.threshold]);
return { ref: elementRef, isIntersecting, entry };
}The Intersection Observer API is perfect for lazy loading images, infinite scrolling, animations, and tracking visibility. This hook wraps the API in a React-friendly way.
import { useEffect, useRef, useState, RefObject } from 'react';
interface UseIntersectionObserverOptions extends IntersectionObserverInit {
triggerOnce?: boolean;
}
interface UseIntersectionObserverReturn {
ref: RefObject<HTMLElement>;
isIntersecting: boolean;
entry: IntersectionObserverEntry | null;
}
export function useIntersectionObserver(
options: UseIntersectionObserverOptions = {}
): UseIntersectionObserverReturn {
const { triggerOnce = false, ...observerOptions } = options;
const [isIntersecting, setIsIntersecting] = useState(false);
const [entry, setEntry] = useState<IntersectionObserverEntry | null>(null);
const elementRef = useRef<HTMLElement>(null);
useEffect(() => {
const element = elementRef.current;
if (!element) return;
const observer = new IntersectionObserver(([entry]) => {
setIsIntersecting(entry.isIntersecting);
setEntry(entry);
if (triggerOnce && entry.isIntersecting) {
observer.disconnect();
}
}, observerOptions);
observer.observe(element);
return () => {
observer.disconnect();
};
}, [triggerOnce, observerOptions.root, observerOptions.rootMargin, observerOptions.threshold]);
return { ref: elementRef, isIntersecting, entry };
}
triggerOnce option to stop observing after first intersection.function LazyImage({ src, alt }: { src: string; alt: string }) {
const { ref, isIntersecting } = useIntersectionObserver({
triggerOnce: true,
});
return (
<div ref={ref}>
{isIntersecting ? (
<img src={src} alt={alt} />
) : (
<div className="placeholder">Loading...</div>
)}
</div>
);
}
function InfiniteScroll({ onLoadMore }: { onLoadMore: () => void }) {
const { ref, isIntersecting } = useIntersectionObserver({
rootMargin: '100px',
});
useEffect(() => {
if (isIntersecting) {
onLoadMore();
}
}, [isIntersecting, onLoadMore]);
return <div ref={ref}>Loading more...</div>;
}
function AnimatedSection({ children }: { children: React.ReactNode }) {
const { ref, isIntersecting } = useIntersectionObserver({
threshold: 0.1,
});
return (
<div
ref={ref}
className={`transition-opacity duration-500 ${
isIntersecting ? 'opacity-100' : 'opacity-0'
}`}
>
{children}
</div>
);
}
triggerOnce is useful for one-time animations or lazy loading.threshold to control when the callback fires (0.0 to 1.0).rootMargin allows you to trigger before the element is fully visible.A powerful hook that brings the Intersection Observer API into React, enabling performant scroll-based features.