Hooks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/**
 * Which box to observe.
 * - `content-box` (default): excludes padding / border — the layer CSS `width` sets.
 * - `border-box`: includes padding + border, excludes margin.
 * - `device-pixel-content-box`: the content box in **device pixels** (already
 *   multiplied by DPR); the one to size a canvas with so it isn't blurry. Narrowest
 *   support (Chromium 84+ / Safari 15.4+, still unimplemented in Firefox).
 */
export type ResizeObserverBoxModel = "content-box" | "border-box" | "device-pixel-content-box"

Installation

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

Prompt

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 .current once 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 notifications is what the browser logs when observer callbacks keep changing the size of observed elements within one frame. Committing state from a requestAnimationFrame (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-box answers "how much room do I have for content", border-box answers "how big is this thing on screen" (padding and border included), device-pixel-content-box answers "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 — contentBoxSize and borderBoxSize were originally a single ResizeObserverSize object and only later became arrays (for multi-fragment elements), and devicePixelContentBoxSize still does not exist in Firefox. Accepting both shapes with a contentRect fallback is what keeps the hook from returning undefined-shaped garbage on the browsers that have not caught up.

On This Page