useVisualViewport
A hook that tracks the visual viewport — size, offsets and pinch scale — and derives the keyboard occlusion so a bottom bar can sit above the on-screen keyboard.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-visual-viewport.jsonPrompt
Build a React + TypeScript "useVisualViewport" hook (React only; it reads the
browser VisualViewport API and nothing else — no npm dependencies).
Contract
- `useVisualViewport(options?: { debounceMs?: number; keyboardThreshold?:
number; trackScroll?: boolean }): VisualViewportState`.
- `VisualViewportState` = `{ width, height, offsetLeft, offsetTop, pageLeft,
pageTop, scale, layoutHeight, keyboardHeight: number; isKeyboardOpen,
isSupported, isReady: boolean }`. Every numeric field is a plain number, never
`undefined` — a consumer writing `paddingBottom: keyboardHeight` must not have
to null-check.
- Defaults: `debounceMs = 0` (meaning rAF coalescing, not "no throttling"),
`keyboardThreshold = 120`, `trackScroll = true`.
- Also export the pure derivation so it can be unit-tested and reused:
`deriveKeyboardHeight({ layoutHeight, height, offsetTop, scale }): number`.
- `layoutHeight` is the LAYOUT viewport height
(`document.documentElement.clientHeight`, falling back to
`window.innerHeight`) — the thing `100vh` is built on. `height` is the VISUAL
viewport height. The gap between the two is the entire product.
Behavior
- Derivation: `occluded = layoutHeight - (offsetTop + height) * scale`, clamped
at 0 and rounded to an integer; that is `keyboardHeight`.
- Multiply by `scale` or a pinch-zoom reads as a keyboard: at scale 2 the
visual viewport height halves with nothing covering the screen, and an
unscaled subtraction would shove the composer half a screen upward.
- Include `offsetTop` because iOS slides the visual viewport down to keep the
caret visible; the occluded strip is what is left below
`offsetTop + height`.
- Clamp because panning a zoomed page downward makes the subtraction
negative — content scrolled out of view is not occlusion.
- Round because Safari reports fractions and 0.5px noise would re-render
forever.
- `isKeyboardOpen` is `keyboardHeight >= keyboardThreshold`, not
`keyboardHeight > 0`: at rest, browser chrome and safe-area rounding leave a
1–3px gap, and a layout that reacts to it visibly twitches. Report the raw
number, gate the boolean.
- Subscribe in an effect, never during render: `visualViewport` "resize" and
(when `trackScroll`) "scroll", plus `window` "resize" and "orientationchange"
because `layoutHeight` is the minuend and changes on its own (desktop window
drag, rotation, Android's resizes-content).
- Coalesce commits. Keyboard animation and pinch gestures fire several events
per frame; with `debounceMs = 0` schedule one `requestAnimationFrame` and
ignore further events until it runs, with `debounceMs > 0` reset a timer so
the commit lands only after the viewport settles.
- Bail out on equal values: compare all twelve fields and return the previous
state object when nothing changed, so identity is stable and consumers
memoizing on it do not re-render on the trailing scroll events a keyboard
dismissal emits.
- SSR/hydration: the initial state is all zeros (scale 1) with `isReady: false`
and `isSupported: false`, and render never touches `window`, so server and
first client render agree. The mount effect measures immediately, which is the
step that flips `isReady`.
- Fallback: when `window.visualViewport` is missing, report `innerWidth` /
`innerHeight`, zero offsets, `pageLeft/pageTop` from `scrollX/scrollY`, scale
1, `keyboardHeight: 0`, `isSupported: false` — usable numbers and an honest
"cannot know", never a fabricated keyboard. In that path also listen to
`window` "scroll" (passive) when `trackScroll`, or the page offsets go stale.
- Cleanup on unmount AND on any option change: remove both viewport listeners,
all window listeners, cancel a pending `requestAnimationFrame` and clear a
pending debounce timer.
- Android note worth putting in the doc comment: Chrome's default
`interactive-widget=resizes-content` shrinks the layout viewport too, so
`keyboardHeight` is legitimately 0 there (the browser already moved the
content). Opt into `interactive-widget=resizes-visual` in the viewport meta
tag to take over the way iOS behaves.
Rendering & styling
- The hook renders no DOM and owns no ARIA. The consuming layout does:
- Apply the inset with `paddingBottom: isKeyboardOpen ? keyboardHeight : 0`
(or `translateY`) on the app shell / fixed bar. Do not size that shell with
`100vh`; `100vh` never notices the keyboard. `100dvh` plus this inset is the
robust pair.
- Keep the focused control on screen: after the inset lands, a
`scrollIntoView({ block: "nearest" })` on the focused field is the honest
finish; the browser's own scroll-anchoring cannot see your fixed bar.
- The lifted bar must remain reachable by keyboard: it is still a normal
`<form>` with a labelled input and a submit button. Never disable the submit
button with the native `disabled` attribute while the user may be standing
on it — the browser blurs it to `<body>` and a keyboard or screen-reader
user loses their place. Use `aria-disabled` plus an early return in the
submit handler.
- Any lift animation is decoration: transition the inset, and disable that
transition under `prefers-reduced-motion` (`motion-reduce:transition-none`).
With motion off the bar still lands in the right place — it just jumps.
- Colours come from semantic tokens only (`bg-card`, `bg-muted`, `bg-primary`
/ `text-primary-foreground` for the sent bubble, `text-muted-foreground` for
metrics, `border`, `ring`), merged with `cn()`.
Customization levers
- `keyboardThreshold`: raise it (160–200) if your app shows a persistent
accessory bar you do not want treated as a keyboard; drop it to ~40 if you
want to react to any occlusion at all, including autofill bars.
- `debounceMs`: leave 0 for layout insets (per-frame is what makes the lift feel
attached to the keyboard); set 100–200 when the reading drives something
expensive like a canvas redraw or a virtualized window recompute.
- `trackScroll: false` drops the per-frame commits during pinch-panning if you
only care about size, not `offsetTop` / `pageTop`.
- Publishing to CSS instead of React: write `keyboardHeight` into a custom
property on `document.documentElement` inside an effect
(`--keyboard-inset`) and let plain CSS consume it — useful when the value is
needed by elements far from the hook's owner. The subscription and cleanup
rules are unchanged.
- Extra derivations that belong in userland, not in the hook: "is the caret
hidden" (compare the field's `getBoundingClientRect().bottom` against
`offsetTop + height`), and a bottom-sheet max height (`height - headerHeight`).
- Pairs with `hooks/use-scroll-lock` (freeze the background while a sheet is
open) and `hooks/use-media-query` (only mount the lifted layout on
touch-primary devices).Concepts
- Two viewports, one screen — the layout viewport is what CSS lengths and
window.innerHeightare measured against; the visual viewport is the part of it the user can actually see right now. A software keyboard on iOS shrinks only the second one, which is exactly why a100vhapp shell keeps rendering its composer underneath the keyboard and looks broken while every number it reads says everything is fine. - Keyboard height as derived occlusion — the hook does not ask the OS how tall the keyboard is (no browser API offers that). It subtracts the visible strip from the layout viewport, so anything covering the bottom — keyboard, IME candidate bar, an accessory toolbar — is reported the same way, and things that only look like shrinking are refused.
- Scale compensation — pinch-zoom halves the reported visual height at scale 2 with nothing occluding anything; multiplying the visible strip back by
scalebefore subtracting is what keeps a zoom gesture from launching the composer up the screen. Panning a zoomed page pushes the same subtraction negative, and the clamp turns that into a plain zero. - Threshold over truthiness — a resting browser routinely reports a couple of pixels of difference. The raw number stays honest;
isKeyboardOpenis the gate that decides whether the layout is allowed to move, so nothing twitches over 2px of address-bar rounding. - Frame-coalesced commits with an equality bail-out — the keyboard animation emits a burst of resize and scroll events; one
requestAnimationFrameper burst, plus a field-by-field comparison that returns the previous object when nothing moved, keeps the consuming tree from re-rendering on trailing events after the value has settled. - Fallback instead of failure — with no
VisualViewportAPI the hook still reports usable window dimensions and setsisSupported: falsewith a permanentkeyboardHeightof 0. That is a documented refusal, not a wrong answer: layouts stay laid out, and only the lift is unavailable.
usePoll
A polling hook that pauses in hidden tabs, catches up on return, skips a tick while a request is still in flight, and backs off through a dynamic interval.
useNetworkInfo
A useSyncExternalStore hook that reports connection quality rather than reachability — effectiveType, downlink, rtt and saveData normalised to nullable fields, plus one coarse quality label.