useResizeObserver
A callback-ref hook that measures an element's own size with ResizeObserver — selectable box model, rAF-batched or debounced commits, and no resize-loop errors.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-resize-observer.jsonPrompt
Build a React + TypeScript "useResizeObserver" hook (no dependencies beyond
React; uses the browser ResizeObserver API only).
Contract
- `useResizeObserver<T extends Element = HTMLElement>(options?: {
box?: "content-box" | "border-box" | "device-pixel-content-box";
debounce?: number;
onResize?: (size: { width: number; height: number;
entry: ResizeObserverEntry }) => void;
disabled?: boolean }): {
ref: (node: T | null) => void;
width: number | undefined; height: number | undefined;
entry: ResizeObserverEntry | null }`.
- Defaults: `box: "content-box"`, `debounce: 0`, `disabled: false`.
- `ref` is a **callback ref**, not a `RefObject` — consumers attach it straight
to the element they want measured: `<div ref={ref} />`.
- `width`/`height` are `undefined` until the first measurement lands (server
render, pre-hydration, or an environment without `ResizeObserver`). Do not
fake a `0`: a consumer must be able to tell "not measured yet" from
"measured, and it really is zero".
Behavior
- The callback ref owns the entire lifecycle. Called with a node: bail out if
`disabled` or `typeof ResizeObserver === "undefined"` (SSR / unsupported),
otherwise construct one observer and `observe(node, { box })`. Called with
`null` (unmount, or the node being replaced): unconditionally `disconnect()`,
cancel the pending rAF and debounce timer, and clear the dedupe baseline.
This is the whole point of a callback ref over `useRef` + `useEffect` — an
effect reads `.current` once at mount, so an element that is conditionally
rendered (mounted later, or swapped for a different DOM node under the same
slot) is silently never observed, or keeps being observed after it detaches.
- `observe()` makes the browser deliver a callback **immediately** with the
element's current size. That first delivery *is* the initial measurement —
never add a manual "measure once on mount" step; it would duplicate work and
drag layout reads into effect/render time.
- Never `setState` synchronously inside the observer callback. Stash the latest
entry in a ref, then commit from `requestAnimationFrame` (when
`debounce === 0`) or `setTimeout(..., debounce)` (when `debounce > 0`).
Reason: if committing re-lays out the observed element, a synchronous
setState makes the browser re-deliver notifications inside the same frame
until it gives up and logs `ResizeObserver loop completed with undelivered
notifications`. Deferring the commit to the next frame breaks that loop —
the resulting size change is simply delivered as a normal next-frame
notification.
- rAF also coalesces bursts: at most one frame is queued at a time, and the
queued commit reads the latest entry out of the ref rather than a stale
closure, so mid-frame deliveries are never lost. In the debounced path each
delivery clears and re-arms the timer, so the commit always sees the final
size.
- Clamp `debounce`: `Number.isFinite(debounce) ? Math.max(0, debounce) : 0`.
A negative value would fire the timer immediately and `NaN` would silently
take the wrong branch of `debounce > 0`.
- Dedupe by value: if the committed width and height equal the previous commit,
skip both `setState` and `onResize` — a padding-only change moves the border
box but not the content box, and should not re-render content-box consumers.
Re-observing (node replaced, `box` changed, coming back from `disabled`)
resets the baseline, so the first delivery of every new observation session
always commits.
- Reading the size out of an entry has three layers of history to absorb:
(1) `contentBoxSize` / `borderBoxSize` shipped as a **single object** in early
Firefox and pre-84 Chrome before the spec changed them to arrays for
multi-fragment elements — accept both shapes; (2) `devicePixelContentBoxSize`
is `undefined` in browsers that never implemented it (Firefox); (3) fall back
to `entry.contentRect`, the oldest and universally present field, so an
unsupported box degrades to a content-box approximation instead of `0`.
Note that `inlineSize`/`blockSize` are writing-mode relative: they map to
width/height under `horizontal-tb` and swap under vertical writing modes.
- Wrap `observe(node, { box })` in try/catch. `box` is a WebIDL enum, so a
browser that does not know `"device-pixel-content-box"` throws a `TypeError`
during argument conversion; retry with plain `observe(node)` so the hook
keeps working on the default box instead of failing silently.
- `onResize` is held in a latest-ref refreshed every render, so it never enters
the callback ref's dependency array. Consumers write inline arrow functions;
putting one in the deps would disconnect and re-observe on every render and
replay the "immediate first delivery" each time.
- `box`, `debounce` and `disabled` *are* dependencies: changing one gives the
callback ref a new identity, and React then calls the old ref with `null` and
the new ref with the node — the disconnect-and-re-observe happens for free,
no extra `useEffect`.
- `disabled: true` stops observation but keeps the last measured values frozen
rather than clearing them, so a temporarily paused panel does not flash back
through its "not measured yet" branch.
- Every hook instance owns its own observer and state; two hooks may observe
the same node (e.g. one per box model) without interfering.
Rendering & styling
- The hook renders nothing. Consumers branch on the measured width — column
counts, which optional panels to mount, canvas backing-store size — and style
with semantic tokens only (`bg-card`, `border`, `text-muted-foreground`,
`bg-primary/5`), merging classes through `cn()`.
- Any transition tied to a size threshold must respect `prefers-reduced-motion`;
the layout switch itself must still happen with animation disabled.
Customization levers
- Threshold table: the breakpoint list is consumer-owned data
(`width < 260 ? 1 : width < 420 ? 2 : 3`) — move it into a prop, or derive it
from a design-token scale, without touching the hook.
- Box model: default `content-box` matches "how much room do I have for
content"; switch to `border-box` when positioning a fixed overlay over the
element, and `device-pixel-content-box` when sizing a canvas backing store
(values are already multiplied by devicePixelRatio).
- Commit policy: `debounce` is the cheap knob (0 = per-frame, 150–300ms = only
when the drag settles). If a consumer needs throttle semantics instead
(emit at most once every N ms *during* the drag), swap the timer branch — the
rest of the hook is unaffected.
- Dedupe policy: rounding the compared values (`Math.round`) suppresses
sub-pixel churn from zoom or fractional layouts if a consumer's downstream
work is expensive.
- Observing many elements: this contract is one element per hook call. For a
grid of measured cells, either call the hook per cell (one observer each) or
refactor to a single shared observer keyed by element — the callback-ref
shape stays the same.
- `entry` is exposed for advanced reads: `entry.contentRect.top/left` for
position-aware effects, `entry.target` when a shared handler needs to know
which element moved.Concepts
- Element size, not viewport size — a viewport breakpoint tells you how wide the window is; a container that lives in a collapsible sidebar, a split pane or a modal can be 300px wide on a 2560px monitor. Observing the element is the only way to make "collapse to one column" a property of the component instead of a property of the page it happens to be dropped into.
- Callback ref over
RefObject+ effect — an effect reads.currentonce at mount. If the observed element is conditionally rendered (mounted a tick later, or swapped for a different node under the same slot), the effect never reruns and the hook silently observes nothing, or keeps observing a detached node. React calls a callback ref on every mount, replace and unmount, so "which node am I observing right now" is correct by construction. - rAF-deferred commit as loop guard —
ResizeObserver loop completed with undelivered notificationsis what the browser logs when observer callbacks keep changing the size of observed elements within one frame. Committing state from arequestAnimationFrame(or a debounce timer) instead of synchronously inside the callback moves the resulting relayout into the next frame, where it is delivered as an ordinary notification — the loop never forms. - First delivery is the measurement —
observe()fires the callback immediately with the current size, so there is never a reason to measure the element by hand on mount. That also keeps the hook off the layout-read path during hydration. - Box model as an explicit choice —
content-boxanswers "how much room do I have for content",border-boxanswers "how big is this thing on screen" (padding and border included),device-pixel-content-boxanswers "how many physical pixels do I need to allocate for a canvas". They can disagree by tens of pixels on the same node, which is why the box is a first-class option rather than an implementation detail. - Legacy shape tolerance —
contentBoxSizeandborderBoxSizewere originally a singleResizeObserverSizeobject and only later became arrays (for multi-fragment elements), anddevicePixelContentBoxSizestill does not exist in Firefox. Accepting both shapes with acontentRectfallback is what keeps the hook from returningundefined-shaped garbage on the browsers that have not caught up.
useSwipe
A pointer-event swipe hook with direction locking, distance-or-velocity commit, and live follow-the-finger state.
useGeolocation
Reads or continuously tracks the browser's geolocation as a six-state machine, with an explicit request()/stop() pair and honest denied / unavailable / timeout branches.