Hooks

useWindowSize

A hook that tracks the viewport size with per-frame or debounced commits and an explicit not-ready state before hydration.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

export interface UseWindowSizeOptions {
  /** Above 0, switch to debouncing (commit once the size has held for N ms); default 0, which commits at most once per rAF frame. */
  debounceMs?: number
  /** Width snapshot for SSR and pre-hydration. Default undefined, which leaves `isReady` false. */
  initialWidth?: number
  /** Height snapshot for SSR and pre-hydration. Default undefined, which leaves `isReady` false. */
  initialHeight?: number
}

export interface WindowSize {

Installation

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

Prompt

Build a React + TypeScript "useWindowSize" hook (no dependencies beyond
React; uses window.innerWidth/innerHeight and DOM events only).

Contract
- `useWindowSize(options?): { width: number | undefined; height: number |
  undefined; isReady: boolean }` with `options?: { debounceMs?: number;
  initialWidth?: number; initialHeight?: number }`.
- Defaults: `debounceMs: 0` (meaning rAF coalescing, not "no throttling"),
  `initialWidth`/`initialHeight` `undefined`.
- `isReady` is false until the first real measurement lands after mount, and
  stays true afterwards. `width`/`height` are `number | undefined` precisely
  so consumers cannot silently treat the pre-hydration guess as a
  measurement — export the result interface so they can narrow it.

Behavior
- Initial state is `{ width: initialWidth, height: initialHeight, isReady:
  false }`; nothing reads `window` during render, so server and first client
  render agree and there is no hydration mismatch.
- One effect owns everything: measure immediately on mount (this is the
  transition that flips `isReady` to true), then subscribe to `resize` and
  `orientationchange` on `window`.
- Throttling has two modes, both of which must exist — a `resize` handler
  that calls `setState` per event re-renders the whole consuming subtree
  dozens of times per drag:
  - `debounceMs === 0`: coalesce with `requestAnimationFrame` — if a frame is
    already scheduled, drop the event; otherwise schedule one commit for the
    next frame. Continuous tracking, at most one render per frame.
  - `debounceMs > 0`: restart a `setTimeout` on every event so the commit
    only happens once the drag has stopped for that long.
- Commit with a functional update that returns the previous state object
  unchanged when `isReady` is already true and both dimensions are identical.
  That keeps the returned object referentially stable, so an
  `orientationchange` that doesn't change the size costs zero renders and
  the result is safe to use in dependency arrays.
- Cleanup removes both listeners and cancels whichever of the pending rAF /
  timeout exists, on unmount and before any re-subscribe.
- `orientationchange` is subscribed alongside `resize` because mobile
  browsers disagree about whether (and when) a rotation fires `resize`.

Rendering & styling
- The hook renders nothing; consumers decide what to show. The important
  rendering rule is to branch on `isReady` rather than on `width === 0`:
  render a skeleton, a token-styled placeholder (`text-muted-foreground`,
  `bg-muted`), or the `initial*` guess — never a layout computed from a
  dimension that doesn't exist yet.
- SSR note: with no `initial*` values the first paint has no numbers at all,
  so anything sized from `width` must have a stable fallback box (a fixed
  aspect ratio, `min-h-*`) to avoid a layout jump when the measurement
  arrives one frame later.

Customization levers
- `debounceMs` — 0 for continuous effects (a canvas that must track the drag),
  150–400ms for expensive recomputations (re-chunking a virtualized list,
  re-laying out a chart) where only the final size matters.
- `initialWidth`/`initialHeight` — supply a guess (a common desktop width, or
  a value derived from a device hint in middleware) when a plausible first
  paint beats an honest empty one; `isReady` still reports that it was a
  guess.
- Derived wrappers — a `useBreakpoint()` that maps `width` to a project's
  breakpoint names, or a `useIsMobile()` boolean, belong on top of this hook
  as thin wrappers; don't fold breakpoint constants into this contract.
- Swapping the source — the same shape works for `visualViewport` (which
  accounts for the mobile keyboard) or `document.documentElement.clientWidth`
  (which excludes the scrollbar); change the two reads inside `commit()` and
  the rest of the machinery is unchanged.

Concepts

  • The not-ready state is part of the contract — a server has no viewport, so the honest first value is "unknown"; encoding it as undefined plus isReady: false (instead of 0 or a silent guess) forces the consuming UI to decide what to paint before the first measurement instead of flashing a wrong layout.
  • rAF coalescing vs. debouncing — coalescing keeps the value continuous while capping work at one commit per frame (right for anything that tracks a drag visually); debouncing waits for stillness and skips every intermediate size (right for expensive recomputations). Same hook, one option apart.
  • Same-value bail-out — returning the previous state object when the measured size is unchanged keeps the result referentially stable, so it can sit in dependency arrays without retriggering effects on every no-op resize event.
  • orientationchange as a second source — rotation on mobile doesn't reliably produce a resize, and when it does the timing relative to the new layout varies by browser; subscribing to both and de-duplicating via the bail-out is cheaper than picking a winner.
  • Cleanup covers pending work, not just listeners — an in-flight rAF or debounce timer would otherwise call setState after unmount; cancelling both is what makes the hook safe inside conditionally-rendered panels.
  • Not a substitute for CSS — this hook exists for JavaScript that needs the number; anything that only changes appearance should stay in Tailwind breakpoints, which cost nothing at runtime and never flash the wrong branch during hydration.

On This Page