usePageVisibility
An SSR-safe hook that subscribes to document visibility so polling, timers and video can pause when the tab goes to the background, and reports how long the user was away.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-page-visibility.jsonPrompt
Build a React + TypeScript "usePageVisibility" hook (no dependencies beyond
React; uses the browser's Page Visibility API — `document.visibilityState`
and the `visibilitychange` event).
Contract
- `usePageVisibility(options?: { onVisible?: (hiddenDurationMs: number) => void;
onHidden?: (hiddenAt: number) => void }): PageVisibilityState`.
- `PageVisibilityState`:
- `isVisible: boolean` — projection of `visibilityState === "visible"`.
- `visibilityState: DocumentVisibilityState` — the raw string, for consumers
that need more than the boolean.
- `hiddenSince: number | null` — timestamp of the most recent switch away.
It is *retained* after the user comes back (so a "you left at 14:03:22"
line is still renderable on return) and is only `null` before the first
switch away.
- `visibleCount: number` — how many times the page has become visible; the
initial visible session counts as 1.
- Both callbacks fire on real transitions only — never on mount or unmount,
because mounting is not a visibility change.
Behavior
- Built on `useSyncExternalStore`, not `useEffect` + `setState`: page
visibility is an external mutable source that changes outside React's render
cycle, which is exactly what that hook exists for.
- `subscribe(callback)`: attach to a module-level store (see below) and
return a cleanup that detaches. Guard `typeof document === "undefined"`
and return a no-op in that case.
- `getSnapshot()`: return a *cached* module-level object. Never build a new
object per call and never read `document` here — `getSnapshot` runs during
render, so a fresh object each time makes React think the store keeps
changing and re-renders forever, and reading `document`/`Date.now()` in
render is impure.
- `getServerSnapshot()`: return one frozen constant meaning "visible"
(`isVisible: true`, `hiddenSince: null`, `visibleCount: 0`). The server has
no document to query; almost every first paint happens in a foreground tab,
so assuming visible avoids hydrating into a wrongly-paused UI.
- One module-level store shared by every instance on the page: a `Set` of
listeners, ref-counted — attach the single `visibilitychange` listener when
the set goes 0 → 1, remove it when it goes back to 0. N components mean one
DOM listener and one shared snapshot, so `visibleCount` can't diverge between
two components on the same page.
- On attach, re-sync the cached snapshot from the live `document` (this runs
inside subscribe, i.e. in an effect, not during render) but do it *silently*:
do not notify listeners and do not fire `onVisible`/`onHidden`. React
re-reads `getSnapshot` right after `subscribe` returns and re-renders if the
value changed, so the correct state still lands without faking a transition.
The re-sync has to reconcile transitions that happened while nobody was
subscribed (no listener was attached, so no event was recorded):
- document hidden but the stored snapshot says visible → the hide was
missed; reset `hiddenSince` to the attach timestamp. Do *not* keep an
older `hiddenSince` from a previous round trip, or the next `onVisible`
will report an away duration measured from minutes ago. The attach
timestamp is an honest approximation — the browser does not expose when
the tab was actually backgrounded before you started observing.
- document hidden and the stored snapshot already says hidden → keep the
original timestamp; re-subscribing must not restart the clock.
- document visible but the stored snapshot says hidden → a return was
missed; count it in `visibleCount` (but still fire no callback).
Share this "next hiddenSince" rule between the event handler and the
re-sync as one function; duplicating it is how the two paths drift.
- Event handling: read `document.visibilityState`, bail out when it equals the
stored one (some browsers re-dispatch on focus/blur churn; deduping keeps
`visibleCount` and `hiddenSince` from counting one switch twice), otherwise
build the next snapshot object, then notify listeners over a *copy* of the
set so a listener that unsubscribes mid-notification cannot corrupt the loop.
- Timestamps (`Date.now()`) are taken only inside the event handler / subscribe
— never during render.
- `onVisible` / `onHidden` use the latest-ref pattern: sync them into refs in
an effect with no dependency array and read `ref.current` inside the store
listener. They must never enter a dependency array — consumers pass inline
arrow functions, and a new identity per render would tear down and re-attach
the subscription on every render.
- Cleanup: unsubscribing removes the listener from the set and, at zero
subscribers, removes the DOM listener. Nothing is left attached.
Rendering & styling
- The hook renders nothing — it returns state. Consumers branch on it: gate a
`setInterval`/`requestAnimationFrame` effect on `isVisible` (return early
while hidden so the cleanup clears the timer), pause a `video` element, or
render a "paused" badge. Anything visual uses semantic tokens
(`bg-muted text-muted-foreground` for the paused state, `bg-primary` for the
live one), never hard-coded colors, and any spinner respects
`motion-reduce:animate-none`.
Customization levers
- Away threshold — ignore short flicks away by comparing `hiddenDurationMs`
in `onVisible` against a threshold (e.g. only refetch after > 30s hidden),
instead of refetching on every micro-switch.
- Resume strategy — `isVisible` gating alone resumes on the *next* interval
tick; add `onVisible` to fire one immediate refetch on return (the
"refetch on focus" behaviour data libraries ship). Pick one or both.
- Server snapshot — flip `getServerSnapshot` to "hidden" only if your app is
mounted primarily in background tabs (rare); the visible default keeps
first paint consistent with the server markup.
- Wider lifecycle — if you also need to stop work when the page is frozen or
bfcache'd, add `pagehide`/`freeze`/`resume` listeners inside the same store
and widen the snapshot; the subscribe/snapshot shape does not change.
- Per-instance vs shared store — the module-level store is what makes N
instances cost one listener; if you need an isolated counter per component,
move the store into a `useRef` factory and drop the ref counting.Concepts
- Page visibility as an external store — visibility flips outside React's render cycle, so it is handed to
useSyncExternalStoreas three primitives (subscribe / snapshot / server snapshot) rather than being mirrored into state by an effect. NosetStatein an effect body, tear-safe under concurrent rendering. - Cached snapshot, not a fresh object —
getSnapshotruns during render and must return a referentially stable value; the store rebuilds its snapshot object only when a realvisibilitychangelands, so idle renders are free and the "getSnapshot should be cached" infinite loop can't happen. - Ref-counted module store — one
Setof listeners for the whole page: thevisibilitychangelistener is attached when the first consumer subscribes and removed when the last one leaves, so ten components mean one DOM listener and one sharedvisibleCountinstead of ten diverging ones. - Silent re-sync on attach — subscribing re-reads the live
documentto correct the snapshot, but deliberately does not fireonVisible/onHidden: mounting is not a transition. React's own post-subscribe snapshot check delivers the corrected value without a fabricated event. The re-sync also repairs switches that happened while nobody was subscribed — a hide it never saw resetshiddenSinceto now (so the next away duration isn't measured from a stale round trip), and a return it never saw still counts invisibleCount. - Latest-ref callbacks —
onVisible/onHiddenare copied into refs after each render and read at event time, so consumers can pass inline arrow functions without the subscription being torn down and rebuilt every render. - Retained
hiddenSincevs. away duration — the timestamp survives the return trip so "you left at 14:03:22" is renderable once the user is back (a value cleared on return would be unreadable, since nobody can see the screen while it is hidden); the precise gap is delivered separately ashiddenDurationMstoonVisible. - Visible ≠ focused —
visibilitychangeanswers "is this document hidden from the user" (other tab, minimized, screen locked, app backgrounded on mobile). A page that is still on screen while another app has focus staysvisible; pausing on focus loss needswindowblur/focusinstead.
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.
useBroadcastChannel
An SSR-safe hook that broadcasts messages between same-origin tabs over BroadcastChannel, with capability detection and an optional local echo.