Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
import { useState, useEffect, useRef, useCallback } from "react";
interface UseIntersectionObserverOptions {
/** Element that is used as the viewport. Default: browser viewport */
root?: Element | null;
/** Margin around the root. Default: "0px" */
rootMargin?: string;
/** Percentage of element visible to trigger. 0-1 or array. Default: 0 */
threshold?: number | number[];
/** Only trigger once (e.g., for lazy loading). Default: false */
triggerOnce?: boolean;
/** Start observing immediately. Default: true */
enabled?: boolean;
}
interface UseIntersectionObserverReturn {
/** Ref to attach to the target element */
ref: (node: Element | null) => void;
/** The latest IntersectionObserverEntry */
entry: IntersectionObserverEntry | null;
/** Whether the element is currently intersecting */
isIntersecting: boolean;
}
function useIntersectionObserver(
options: UseIntersectionObserverOptions = {}
): UseIntersectionObserverReturn {
const {
root = null,
rootMargin = "0px",
threshold = 0,
triggerOnce = false,
enabled = true,
} = options;
const [entry, setEntry] = useState<IntersectionObserverEntry | null>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const nodeRef = useRef<Element | null>(null);
const frozenRef = useRef(false);
const cleanup = useCallback(() => {
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
}, []);
// Callback ref pattern for flexible element targeting
const ref = useCallback(
(node: Element | null) => {
// Cleanup previous observer
cleanup();
nodeRef.current = node;
if (!node || !enabled || frozenRef.current) return;
if (typeof IntersectionObserver === "undefined") return;
observerRef.current = new IntersectionObserver(
([observedEntry]) => {
setEntry(observedEntry);
if (triggerOnce && observedEntry.isIntersecting) {
frozenRef.current = true;
cleanup();
}
},
{ root, rootMargin, threshold }
);
observerRef.current.observe(node);
},
[root, rootMargin, threshold, triggerOnce, enabled, cleanup]
);
// Cleanup on unmount
useEffect(() => {
return cleanup;
}, [cleanup]);
return {
ref,
entry,
isIntersecting: entry?.isIntersecting ?? false,
};
}When to reach for this: You need lazy-loaded images, infinite scroll triggers, animate-on-scroll effects, or tracking which sections the user has scrolled to.
"use client";
// Lazy-loaded image
function LazyImage({ src, alt }: { src: string; alt: string }) {
const { ref, isIntersecting } = useIntersectionObserver({
triggerOnce: true,
rootMargin: "200px", // Start loading 200px before visible
});
return (
<div ref={ref} style={{ minHeight: 200, background: "#f0f0f0" }}>
{isIntersecting ? (
<img src={src} alt={alt} style={{ width: "100%" }} />
) : (
<div style={{ padding: 20, color: "#999" }}>Loading...</div>
)}
</div>
);
}
// Infinite scroll trigger
function InfiniteList() {
const [items, setItems] = useState<number[]>([1, 2, 3, 4, 5]);
const [loading, setLoading] = useState(false);
const { ref, isIntersecting } = useIntersectionObserver({
threshold: 1.0,
});
useEffect(() => {
if (!isIntersecting || loading) return;
setLoading(true);
// Simulate API call
setTimeout(() => {
setItems((prev) => [
...prev,
...Array.from({ length: 5 }, (_, i) => prev.length + i + 1),
]);
setLoading(false);
}, 500);
}, [isIntersecting, loading]);
return (
<div>
{items.map((item) => (
<div key={item} style={{ padding: 24, borderBottom: "1px solid #eee" }}>
Item {item}
</div>
))}
<div ref={ref} style={{ padding: 20, textAlign: "center" }}>
{loading ? "Loading more..." : "Scroll for more"}
</div>
</div>
);
}
// Animate on scroll
function AnimatedSection({ children }: { children: React.ReactNode }) {
const { ref, isIntersecting } = useIntersectionObserver({
threshold: 0.2,
triggerOnce: true,
});
return (
<div
ref={ref}
style={{
opacity: isIntersecting ? 1 : 0,
transform: isIntersecting ? "translateY(0)" : "translateY(20px)",
transition: "opacity 0.6s ease, transform 0.6s ease",
}}
>
{children}
</div>
);
}What this demonstrates:
threshold: 1.0)triggerOnce so it does not replayuseRef, the hook uses a callback ref (node) => .... This lets it re-observe when the target element changes (e.g., conditional rendering)."200px 0px") that expands the detection area, allowing preloading before the element is visible.0 means any pixel; 1 means fully visible.| Option | Type | Default | Description |
|---|---|---|---|
root | Element or null | null (viewport) | Scrollable ancestor to use as viewport |
rootMargin | string | "0px" | Margin around root to expand detection |
threshold | number or number[] | 0 | Visibility ratio to trigger |
triggerOnce | boolean | false | Disconnect after first intersection |
enabled | boolean | true | Whether to observe |
| Return | Type | Description |
|---|---|---|
ref | (node: Element or null) => void | Callback ref to attach to target |
entry | IntersectionObserverEntry or null | Latest observer entry |
isIntersecting | boolean | Whether the element is visible |
Multiple elements: Observe many elements with a single observer for better performance:
function useIntersectionObserverMultiple(
options: IntersectionObserverInit = {}
) {
const [entries, setEntries] = useState<Map<Element, IntersectionObserverEntry>>(new Map());
const observer = useRef<IntersectionObserver | null>(null);
const observe = useCallback((node: Element) => {
if (!observer.current) {
observer.current = new IntersectionObserver((observed) => {
setEntries((prev) => {
const next = new Map(prev);
observed.forEach((e) => next.set(e.target, e));
return next;
});
}, options);
}
observer.current.observe(node);
}, [options]);
return { observe, entries };
}With intersection ratio: Track the exact visibility percentage for scroll-linked animations:
const { entry } = useIntersectionObserver({
threshold: Array.from({ length: 101 }, (_, i) => i / 100),
});
const ratio = entry?.intersectionRatio ?? 0; // 0.0 to 1.0Element | null, compatible with any HTML or SVG element.IntersectionObserverEntry is a built-in browser type with isIntersecting, intersectionRatio, boundingClientRect, etc.IntersectionObserver does not exist on the server. Fix: The typeof IntersectionObserver === "undefined" guard handles this.isIntersecting."10px 20px", not a number. Fix: Validate the format or document it clearly.| Package | Hook Name | Notes |
|---|---|---|
react-intersection-observer | useInView | Most popular, full-featured |
usehooks-ts | useIntersectionObserver | Simple, ref-based |
ahooks | useInViewport | Part of a large collection |
@uidotdev/usehooks | useIntersectionObserver | Minimal implementation |
framer-motion | useInView | Animation-focused |
(node) => ... that React calls when the element mounts or changes.useRef, it lets the hook re-observe when the target element is conditionally rendered.When triggerOnce is true and the element becomes visible, the hook sets a frozen flag, disconnects the observer, and stops watching. This prevents further callbacks, improving performance for lazy-loaded content.
rootMargin is a CSS-like margin string (e.g., "200px 0px") that expands the detection area. Setting rootMargin: "200px" triggers intersection 200px before the element enters the viewport, allowing preloading of images or data.
0.5 means 50% visible).[0, 0.25, 0.5, 0.75, 1]).0 means any pixel visible; 1 means fully visible.Fine-grained thresholds (e.g., Array.from({ length: 101 }, (_, i) => i / 100)) fire the callback 100 times as the element scrolls through. Only use this for scroll-linked animations. For lazy loading, threshold: 0 or threshold: 1 is sufficient.
The hook guards with typeof IntersectionObserver === "undefined". On the server, it returns early without creating an observer. isIntersecting defaults to false.
Use the multi-element variation shown in the Variations section. Create one IntersectionObserver and call .observe(node) for each element. Store entries in a Map keyed by the target element.
Yes. The callback ref accepts Element | null, which covers both HTML and SVG elements. IntersectionObserver works with any DOM element type.
IntersectionObserverEntry is a built-in browser type with properties including isIntersecting, intersectionRatio, boundingClientRect, rootBounds, and target. No custom types are needed.
When enabled is false, the callback ref skips creating an observer. This lets you conditionally pause observation without unmounting the element, and re-enable it later by setting enabled back to true.
Reviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥