useWakeLock
A screen wake lock hook that keeps the display on for as long as a task lasts — and re-acquires the lock the browser silently takes away every time the tab goes to the background.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-wake-lock.jsonPrompt
Build a React + TypeScript "useWakeLock" hook (no dependencies beyond React; uses
the browser's Screen Wake Lock API — navigator.wakeLock.request("screen") and the
WakeLockSentinel it hands back).
Contract
- `useWakeLock(options?: { autoReacquire?: boolean; onError?: (error:
WakeLockErrorInfo) => void }): UseWakeLockResult`, `autoReacquire` defaults to
`true`.
- `WakeLockErrorInfo`: `{ name: string; message: string }` — carry *both*. One
`"NotAllowedError"` covers at least three different situations (the page was
not visible, the request did not come from a user gesture, the browser refused
it because the battery is critically low) and the browser's message text is the
only thing that tells them apart. Throwing away `message` makes the error
useless for writing UI copy.
- `UseWakeLockResult`:
- `isSupported: boolean` — whether this environment exposes
`navigator.wakeLock`. `false` during SSR and the first hydration render.
- `isActive: boolean` — whether a live, unreleased sentinel is held right now.
This is reality, not intent: it flips to `false` on its own when the browser
takes the lock away.
- `request(): Promise<boolean>` — never throws; resolves `false` on refusal and
writes the reason into `error`.
- `release(): Promise<void>` — never throws; also clears the "I want the screen
awake" intent, so nothing re-acquires later.
- `error: WakeLockErrorInfo | null` — cleared back to `null` by a successful
acquisition.
Behavior
- **Never request on mount.** Two reasons, both load-bearing: the request is
meant to sit inside a user gesture, and silently pinning someone's screen on
because they opened a page is hostile. Acquisition is always the consumer's
call, from their own interaction.
- Keep two separate facts:
- a `sentinelRef` holding the current `WakeLockSentinel` (reality), and
- a `wantedRef` boolean holding the consumer's intent.
They diverge exactly when the browser takes the lock away, and that gap is what
makes automatic re-acquisition possible.
- The browser releases every screen wake lock the moment the document becomes
hidden (other tab, minimised window, screen locked, app backgrounded) and never
returns it when the user comes back. So:
- Listen for the sentinel's `release` event with `{ once: true }` — a sentinel
can only be released once. Do not poll `sentinel.released`; it is a read-only
snapshot, not a notification.
- In that listener, only act if the fired sentinel is still the one in
`sentinelRef`; a late event from a superseded sentinel must not knock the new
lock's state down to `false`.
- With `autoReacquire`, add one `visibilitychange` listener on `document`: when
the page becomes visible again, the intent is still set, and no live sentinel
is held, request a new one. Requesting from that handler is allowed — no
fresh gesture is needed. Guard with `sentinel && !sentinel.released` rather
than "sentinel is null", because some browsers deliver the release event
after the visibility change, and `released` is true either way.
- Failures clear the intent, with one exception. A refusal (low battery, blocked
by permissions policy) that clears intent is what keeps a permanent refusal
from turning into an error every time the user comes back to the tab. The
exception: if the document is no longer visible by the time the request
settles, the rejection is a lost race, not a refusal — keep the intent so the
next return re-acquires. Skipping this exception is a real, reproducible dead
state: come back and leave again within the same tick, and the consumer's
switch is left on with no lock, no retry, and a "the page is not visible"
error showing while the user is looking at the page.
- Call `navigator.wakeLock.request("screen")` with no `await` in front of it, so
that calling `request()` from a click handler keeps the call inside the
gesture's synchronous call stack. (Chromium currently grants screen wake locks
without any user activation — verified — but the spec permits a user agent to
require one, so do not architect around its absence.)
- Re-check the world after the `await`: if the component unmounted or the
consumer already called `release()` while the promise was in flight, release
the freshly-granted sentinel immediately. An unclaimed sentinel keeps the
screen on forever with nobody left to turn it off.
- De-duplicate concurrent acquisitions with a pending-promise ref, so a double
click, or a click racing a visibility-driven re-acquisition, produces one
sentinel rather than two.
- Capability detection never runs during render: expose `isSupported` through
`useSyncExternalStore` with a no-op subscribe and a server snapshot of `false`,
and probe `"wakeLock" in navigator` directly inside the async acquire path so
the acquire callback keeps a stable identity.
- `autoReacquire` and `onError` go into latest-refs read at event time, never
into a dependency array. The effect's cleanup *releases the lock*, so a
re-running effect would put the screen out on every render.
- Cleanup on unmount: remove the `visibilitychange` listener, drop the intent,
and release a held sentinel — it lives on `document`, so it outlives the
component unless you hand it back. Set the `mounted` ref to `true` inside the
effect body (not only `false` in the cleanup), otherwise StrictMode's
mount → cleanup → mount leaves it `false` for the live instance and every
acquisition is discarded as "unmounted".
Rendering & styling
- The hook renders nothing. Consumers usually drive one toggle plus a readout.
Bind the toggle's label and `aria-pressed` to `isActive` (reality) rather than
to their own intent flag, so a refused request cannot render as a success.
Anything visual uses semantic tokens (`bg-primary` / `text-primary-foreground`
for the engaged state, `bg-muted text-muted-foreground` for idle,
`border-destructive/40 bg-destructive/10` for the error panel) and any spinner
carries `motion-reduce:animate-none`.
Customization levers
- `autoReacquire` — off gives you the raw API's behaviour (one trip to another
tab and the lock is gone for good); on is the reason this hook exists. Flip it
per instance.
- Re-acquire trigger — `visibilitychange` is the correct signal for the lock
being taken. If you also want to recover after a bfcache restore, listen for
`pageshow` inside the same effect and reuse the same guard.
- Intent policy on failure — the default drops intent on a real refusal. Keeping
it (and retrying on every return) suits a kiosk that must fight to stay awake;
keep it only if you are certain a silent retry can never surprise the user.
- Lock type — the spec only standardises `"screen"`. If a future `"system"` type
ships, make the type a parameter; the sentinel plumbing does not change.
- Scope — the hook is per-consumer, so several components can each hold their own
sentinel. To make the whole app share one lock (and one re-acquire path), lift
the sentinel, intent and listener into a module-level ref-counted store the way
a page-visibility store does, and keep this exact return shape.
- Auto-release timeout — pair it with an idle detector and call `release()` after
N minutes of no interaction if you do not want a forgotten tab holding the
screen on all night.Concepts
- Released when hidden, never returned — the browser drops every screen wake lock the instant the document becomes hidden and does not hand it back on return. That single fact is what separates this hook from a two-line
navigator.wakeLock.request()call: without avisibilitychangere-acquisition, one glance at another tab silently ends the "keep awake" promise. - Intent vs. reality —
isActivetracks the live sentinel, a separate internal flag tracks "the consumer asked for this". They only diverge while the page is in the background, and that gap is exactly what authorises an automatic re-acquisition; binding UI toisActiveis what keeps a refused request from rendering as a success. releasedis a snapshot,releaseis the event —sentinel.releasedanswers "is it gone right now" and can only be polled; thereleaseevent is the notification, fires at most once per sentinel, and is therefore registered with{ once: true }. A late event from a superseded sentinel is ignored by identity check.- Gesture-scoped request — the call to
navigator.wakeLock.request()is made with no precedingawait, so invoking it from a click handler keeps it in the gesture's synchronous call stack. Chromium grants it without activation today; the spec allows a user agent to demand one, and being inside the gesture costs nothing. - Lost race ≠ refusal — a rejection that lands after the page has gone hidden again is not the browser saying no to the user's intent, so the intent survives and the next return retries. Treating it as a normal failure leaves a switch that reads "on" while nothing is held and nothing will ever retry.
- Nobody's lock but the consumer's — no acquisition happens on mount, and unmount releases and forgets. A sentinel lives on
document, not in the component, so an unclaimed one (granted after unmount, or after the user changed their mind mid-flight) would keep the screen lit with no owner left to turn it off.
useClipboardPaste
Catches pasted images, files and rich text — page-wide or scoped to a ref — draining clipboardData synchronously, gating files through accept/maxFiles with an itemised rejection list, plus an optional permission-checked active read.
useHash
An SSR-safe hook that reads and writes the URL hash as shareable UI state, using pushState/replaceState instead of assigning location.hash.