Hooks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/** A normalised failure reason. Consumers get **both** `name` and `message`: one
 *  `"NotAllowedError"` covers at least three causes (the page was not visible / the
 *  call did not come from a user gesture / the browser refused on low battery), and
 *  only the browser's own wording tells them apart. */
export interface WakeLockErrorInfo {
  /** `DOMException.name`, e.g. `"NotAllowedError"` / `"NotSupportedError"`. */
  name: string
  /** The browser's own wording. It is what separates same-named failures — do not drop it. */
  message: string
}

Installation

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

Prompt

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 a visibilitychange re-acquisition, one glance at another tab silently ends the "keep awake" promise.
  • Intent vs. realityisActive tracks 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 to isActive is what keeps a refused request from rendering as a success.
  • released is a snapshot, release is the eventsentinel.released answers "is it gone right now" and can only be polled; the release event 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 preceding await, 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.

On This Page