useIntersectionObserver — Observe when elements enter or exit the viewport
Recipe
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.
Working Example
"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:
- Lazy image: Only loads when within 200 px of the viewport, fires once
- Infinite scroll: Triggers data fetching when the sentinel element is fully visible (
threshold: 1.0) - Animate on scroll: Fades in content when 20% visible, using
triggerOnceso it does not replay
Deep Dive
How It Works
- Callback ref pattern: Instead of a plain
useRef, the hook uses a callback ref(node) => .... This lets it re-observe when the target element changes (e.g., conditional rendering). - IntersectionObserver: A browser API that asynchronously observes visibility changes. It runs off the main thread, so it is more performant than scroll listeners.
- triggerOnce: When enabled, the observer disconnects after the first intersection, preventing further callbacks and improving performance for lazy-loaded content.
- rootMargin: A CSS-like margin string (
"200px 0px") that expands the detection area, allowing preloading before the element is visible. - threshold: A number (0 to 1) or array of numbers indicating what percentage of the element must be visible to trigger.
0means any pixel;1means fully visible.
Parameters & Return Values
| 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 |
Variations
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.0TypeScript Notes
- The callback ref accepts
Element | null, compatible with any HTML or SVG element. IntersectionObserverEntryis a built-in browser type withisIntersecting,intersectionRatio,boundingClientRect, etc.- Options use an interface so they can be extended in consuming hooks.
Gotchas
- Observer not created during SSR —
IntersectionObserverdoes not exist on the server. Fix: Thetypeof IntersectionObserver === "undefined"guard handles this. - Stale entry after unmount — The entry state may reference a disconnected element. Fix: Cleanup disconnects the observer; consumers should guard with
isIntersecting. - Threshold array creates many callbacks — Using 100 thresholds fires the callback 100 times as the element scrolls through. Fix: Only use fine-grained thresholds when needed for scroll-linked animations.
- Callback ref re-creates observer — If the callback ref dependencies change, it creates a new observer. Fix: Memoize the options or pass stable references.
- rootMargin format — Must be a CSS-style string like
"10px 20px", not a number. Fix: Validate the format or document it clearly.
Alternatives
| 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 |
FAQs
What is the callback ref pattern and why does this hook use it instead of useRef?
- A callback ref is a function
(node) => ...that React calls when the element mounts or changes. - Unlike
useRef, it lets the hook re-observe when the target element is conditionally rendered. - The observer is created inside the callback, ensuring it always targets the correct node.
What does the triggerOnce option do internally?
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.
What is rootMargin and how do I use it for preloading?
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.
What values can threshold take?
- A single number from 0 to 1 (e.g.,
0.5means 50% visible). - An array of numbers for multiple trigger points (e.g.,
[0, 0.25, 0.5, 0.75, 1]). 0means any pixel visible;1means fully visible.
Gotcha: I used 100 threshold values and my callback fires excessively. What should I do?
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.
Gotcha: The observer does not work during SSR. How is this handled?
The hook guards with typeof IntersectionObserver === "undefined". On the server, it returns early without creating an observer. isIntersecting defaults to false.
How do I observe multiple elements with a single observer?
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.
Can I use this hook with SVG elements?
Yes. The callback ref accepts Element | null, which covers both HTML and SVG elements. IntersectionObserver works with any DOM element type.
How is IntersectionObserverEntry typed in TypeScript?
IntersectionObserverEntry is a built-in browser type with properties including isIntersecting, intersectionRatio, boundingClientRect, rootBounds, and target. No custom types are needed.
What does the enabled option do?
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.
Related
- useScrollToTop — scroll-based UI
- useEventListener — alternative scroll detection
- useWindowSize — viewport dimension tracking