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…

"use client"

import * as React from "react"

export interface UseIntersectionObserverOptions {
  /** Passed through to `IntersectionObserver` as `threshold`. Default 0. */
  threshold?: number | number[]
  /** Passed through to `IntersectionObserver` as `rootMargin`. Default "0px". */
  rootMargin?: string
  /** Disconnect the observer the first time the element intersects. Default false. */
  once?: boolean
}

export interface UseIntersectionObserverResult<T extends Element> {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-intersection-observer.json

Prompt

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 — a RefObject only gets read once, at mount, inside a useEffect; 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 the IntersectionObserver's own (asynchronous) callback, never synchronously in the ref callback or render body, which is what keeps this hook clear of React's "no setState during render/effect body" pitfalls entirely.
  • Options rebuild keythreshold/rootMargin/once are collapsed into one serialized key before being used as a memo dependency, so an inline array literal for threshold doesn't defeat the memoization the way comparing it by reference would.
  • Once mode as a one-way latchonce: true turns isIntersecting from 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 no typeof window guard is needed at the top of the hook.

On This Page