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 guardResizeObserver 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 measurementobserve() 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 choicecontent-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 tolerancecontentBoxSize 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