Hooks
useIntersectionObserver
A callback-ref hook that reports whether an element is intersecting its viewport, with an optional one-shot "reveal once" mode.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-intersection-observer.jsonPrompt
Build a React + TypeScript "useIntersectionObserver" hook (no dependencies
beyond React; uses the browser IntersectionObserver API only).
Contract
- `useIntersectionObserver<T extends Element = HTMLElement>(options?: {
threshold?: number | number[]; rootMargin?: string; once?: boolean }): {
ref: (node: T | null) => void; isIntersecting: boolean; entry:
IntersectionObserverEntry | null }`.
- `threshold` defaults to `0`, `rootMargin` defaults to `"0px"`, `once`
defaults to `false`.
- `ref` is a **callback ref**, not a `RefObject` — consumers attach it
directly to the element they want observed: `<div ref={ref}>`.
Behavior
- The callback ref is the only place an observer is ever created or torn
down: when React calls it with a real node, build an `IntersectionObserver`
(guarded by `typeof IntersectionObserver === "undefined"` for SSR /
unsupported environments — bail out silently) and `observe()` it; when
React calls it with `null` (unmount, or the node being replaced), first
unconditionally `disconnect()` whatever observer is currently held before
doing anything else.
- Every `setState` call (`isIntersecting`, `entry`) happens inside the
observer's own callback — which fires asynchronously — never synchronously
during render or inside a bare mount effect.
- `once: true`: the moment an entry reports `isIntersecting`, disconnect the
observer immediately after committing that state. `isIntersecting` then
stays `true` for the rest of the component's life; scrolling the element
back out of view no longer flips it back.
- `threshold` is commonly an inline array literal (`[0, 0.5]`) that a
consumer writes fresh on every render — comparing it by reference would
force a disconnect/reconnect on every render even when the values are
unchanged. Rebuild the observer's init dict off a key derived from
`JSON.stringify(threshold) + rootMargin` (memoized), not off `threshold`
itself, so the callback ref's identity — and therefore the actual
`IntersectionObserver` instance — stays stable across renders where the
values didn't really change. When `threshold`/`rootMargin`/`once` do
change, React swaps the callback ref's identity, which triggers the
built-in "call the old ref with `null`, then the new ref with the node"
sequence — the disconnect-and-rebuild happens for free, no extra
`useEffect` needed.
- Multiple components calling `useIntersectionObserver()` each own an
independent observer and state; there is no shared/global visibility store.
Rendering & styling
- The hook renders nothing itself and owns no visual output — consumers
branch on `isIntersecting` (conditional classes via `cn()`, semantic
tokens like `bg-primary`/`text-muted-foreground` for any feedback) and
respect `prefers-reduced-motion` in whatever entrance animation they build
on top.
Customization levers
- Observing a non-viewport scroll container: this contract intentionally
doesn't expose `root` — add it as an extra option if a consumer needs to
observe relative to a scrollable ancestor instead of the viewport
(`IntersectionObserver`'s default root already accounts for clipping by
`overflow` ancestors, so most scroll-container cases work without it).
- `entry` is exposed (not just the `isIntersecting` boolean) for advanced
reads — `entry.intersectionRatio` for a continuous fade amount,
`entry.boundingClientRect` for position-aware effects.
- Natural pairing: feed `isIntersecting` (or its `once` variant) into
`text-reveal`'s or `number-ticker`'s "start on view" trigger instead of
their built-in one, if a single shared observer should drive several
elements' entrance timing together.Concepts
- Callback ref over
RefObject+ effect — aRefObjectonly gets read once, at mount, inside auseEffect; if the observed element is conditionally rendered and later replaced by a different DOM node in the same spot, the effect never reruns and the hook silently keeps watching a detached element. A callback ref is invoked by React on every mount, replace, and unmount, so re-observing the current node is automatic instead of something you have to remember to re-trigger. - Async-only
setState— every state update happens inside theIntersectionObserver's own (asynchronous) callback, never synchronously in the ref callback or render body, which is what keeps this hook clear of React's "nosetStateduring render/effect body" pitfalls entirely. - Options rebuild key —
threshold/rootMargin/onceare collapsed into one serialized key before being used as a memo dependency, so an inline array literal forthresholddoesn't defeat the memoization the way comparing it by reference would. - Once mode as a one-way latch —
once: trueturnsisIntersectingfrom a live toggle into a fired-and-forgotten flag: useful for entrance animations that should never play in reverse when a user scrolls back up past them. - SSR safety by construction — the observer is only ever constructed inside the callback ref, which React never invokes during server rendering; there is no module-level or render-time touch of
IntersectionObserver, so notypeof windowguard is needed at the top of the hook.