useBattery
Reads battery level, charging state and time remaining from the Battery Status API, with a four-way support status and a low-power flag that fails open when no reading is available.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-battery.jsonPrompt
Build a React + TypeScript "useBattery" hook (no dependencies beyond React; it
reads the browser's Battery Status API — navigator.getBattery() and the
BatteryManager it resolves with).
Contract
- `useBattery(options?: { lowThreshold?: number }): UseBatteryResult`.
`lowThreshold` defaults to `0.2` and is a 0..1 fraction, not a percentage.
- `BatteryStatus = "pending" | "ready" | "unsupported" | "blocked"`. Four
outcomes, not one `isSupported` boolean, because "no reading" has three
different causes and each one needs different UI copy:
- `pending` — no verdict yet: the server render, the first hydration render,
and the window while the `getBattery()` promise is in flight.
- `ready` — a BatteryManager was handed over; the readings are live.
- `unsupported` — the environment exposes no `navigator.getBattery` at all.
Safari never shipped it and Firefox removed it in 52, so this is the
majority outcome on the open web.
- `blocked` — the method exists but the call was refused: a cross-origin
iframe without `allow="battery"`, a `Permissions-Policy: battery=()`
header, or a platform-level refusal. The reason lands in `error`.
- `UseBatteryResult`:
- `status: BatteryStatus`
- `isSupported: boolean` — whether `navigator.getBattery` exists here.
`false` during SSR and the first hydration render, on purpose.
- `level: number | null` (0..1), `charging: boolean | null`
- `chargingTime: number | null`, `dischargingTime: number | null`, seconds
- `timeRemaining: number | null` — whichever of the two applies to the
current `charging` value
- `isLow: boolean` — derived, see below
- `error: { name: string; message: string } | null` — keep both fields; the
browser's message text is the only thing that separates two refusals that
share the name `NotAllowedError`.
Every reading is `null` until a real value arrives. Never substitute `0`:
0% is a legitimate battery level and `null` is not, and a UI that cannot
tell them apart will render "0% — plug in now" on a desktop PC.
- Also export the pure derivation `deriveBatteryReading(snapshot,
lowThreshold?)` and have the hook itself call it. That is what lets a
consumer render their own battery UI against a hand-written snapshot in a
test, a story, or a docs page — the states the developer's browser cannot
produce are precisely the ones that ship broken.
Behavior
- Normalise `Infinity` to `null`. The API uses `Infinity` to mean "unknown or
not applicable": `dischargingTime` is `Infinity` whenever the device is
charging and `chargingTime` is `Infinity` whenever it is discharging, and
either can be `Infinity` simply because the OS has no estimate yet (very
common in the first seconds after unplugging). `Infinity` also does not
survive `JSON.stringify`, so passing it straight through poisons any
consumer that persists or posts the reading.
- `isLow` fails open. It is `true` only when the hook *knows* the device is
discharging (`charging === false`) and `level <= lowThreshold`; `pending`,
`unsupported` and `blocked` all yield `false`. This is the ethic of the whole
hook: use `isLow` to *defer* optional work — background sync, prefetch,
autoplay, particle animation, polling cadence — and never to disable
something the user explicitly asked for. A feature gated on a battery
reading is a feature that silently behaves differently in Safari, in a
cross-origin iframe, and on a desktop with no battery, in ways the author
cannot reproduce locally.
- One module-level, ref-counted store shared by every caller, exposed through
`useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)`:
- `getBattery()` resolves the *same* BatteryManager for the lifetime of a
document, so N components each calling it means N promise chains and N×4
listeners for one underlying object. Call it once, refcount the
subscribers.
- `getSnapshot` returns a cached object. Building a fresh object per call
makes React believe the store changes on every render and loops forever.
Compare field by field before swapping the cached snapshot in, because
`chargingtimechange` fires repeatedly with identical numbers.
- `getServerSnapshot` returns the shared `pending` constant, so SSR and the
first hydration render agree by construction.
- Capability detection happens inside `subscribe` (which React only calls in
a commit-phase effect), never during render. Reading `navigator` in the
render body is the classic hydration mismatch: it does not exist on the
server and does on the client.
- Attach all four events — `levelchange`, `chargingchange`,
`chargingtimechange`, `dischargingtimechange`. Dropping the two time events
leaves a "2h left" label frozen on screen while the level ticks down.
- Failure paths, all of them: `getBattery` missing → `unsupported`;
`getBattery()` throwing synchronously (some environments refuse that way) →
`blocked`; the promise rejecting → `blocked` with the normalised error. A
rejection must never surface as a zeroed-out reading.
- The promise can settle after the last subscriber has gone (mount, unmount,
resolve). In that case cache the manager and the snapshot but do not attach
listeners; the next subscriber attaches them.
- Re-subscribe path: the cached manager is reused, listeners are re-attached,
and the snapshot is re-read once — changes that happened while nobody was
subscribed produced events nobody kept, so the cached snapshot is stale by
definition. Perform that re-read silently (no broadcast): React re-reads the
snapshot itself right after `subscribe` returns, and the only instance that
can reach this branch is the one that just took the count from 0 to 1.
- Cleanup: when the last subscriber unsubscribes, remove all four listeners.
The BatteryManager lives as long as the document, so a forgotten listener is
a leak that outlives every component that caused it.
- Honest limits to document rather than paper over: a desktop with no battery
reports `level: 1, charging: true, chargingTime: 0` in Chromium and is
indistinguishable from a fully charged laptop on mains; Chromium quantises
`level` to 1% steps and coarsens the time estimates, so these numbers cannot
drive precise billing or a countdown; and battery data is a known
fingerprinting vector, which is why the spec was removed elsewhere — do not
send it to analytics.
Rendering & styling
- The hook renders nothing. A consumer readout typically pairs a level bar
with a status badge: `role="progressbar"` plus `aria-valuenow` /
`aria-valuemin` / `aria-valuemax` and an `aria-label` when a level exists,
and a plain `aria-hidden` track plus a text dash when it is `null` — an
indeterminate progressbar that claims a value is worse than no progressbar.
- Semantic tokens only: `bg-muted` for the track, `bg-primary` for the fill,
`bg-destructive` (or `text-destructive`) for the low state,
`border-destructive/40 bg-destructive/10` for a refusal panel,
`text-muted-foreground` for captions. Tinted panels carry `text-foreground`
rather than `text-muted-foreground`, which drops under AA over a tint.
- Any spinner or width transition carries `motion-reduce:animate-none` /
`motion-reduce:transition-none`; the deferral logic itself must work
unchanged with motion off.
- A control that refuses under low battery must not use the native `disabled`
attribute — the browser blurs it and focus falls to `<body>`. Keep it
focusable and let it answer with a visible, `role="status"` explanation.
Customization levers
- `lowThreshold` — per call, so a heavy video prefetcher can back off at 0.35
while a cheap poll only backs off at 0.1. It is a fraction; a percentage
slips through the type system and disables the flag forever.
- Add hysteresis if a reading is allowed to drive a visible mode: exiting
low-power at `lowThreshold + 0.05` stops a device parked exactly on the line
from flapping between modes on every quantised 1% step.
- Add `onLow` / `onNormal` callbacks held in latest-refs (never in a
dependency array) if the consumer needs an edge trigger — a toast, a one-off
downgrade — rather than a rendered flag.
- `deriveBatteryReading` is the seam for design and testing: feed it fixed
snapshots to build every state (pending, charging, low, unknown estimate, no
battery, unsupported, blocked) without a device that can produce them.
- Scope: the store is module-level and shared. To make one subtree read a
different (for example simulated) battery, pass a reading down through
context instead of adding a mock mode to the hook — the hook stays the one
place that touches the platform.
- Natural pairing: gate work on `usePageVisibility` first (the tab being
hidden is a bigger, universally supported win) and use `useBattery` as the
second, narrower condition.Concepts
- Four-way status over one boolean — "we have no reading" splits into not asked yet, this browser has no such API, and the API refused us. Collapsing them into
isSupported: falsethrows away the only information that decides whether to retry, to explain, or to say nothing at all — and makes the SSR frame indistinguishable from a permanent refusal. - Fail-open degradation — the low-power flag is only ever true when the hook positively knows the device is discharging below the line. Every uncertain state resolves to "assume mains power", which is what keeps a capability that most browsers refuse from quietly making the product worse for those browsers.
- Defer, do not disable — the flag belongs on work the user never asked for (background sync, prefetch, autoplay, decorative motion). Wiring it to a button or a feature toggle produces behaviour that changes with the user's charger and cannot be reproduced on the developer's machine.
- Infinity as "unknown" — the platform encodes "not applicable" and "no estimate yet" as
Infinityon both time fields. Normalising tonullat the boundary is what stopsInfinityfrom reaching arithmetic,toFixed, orJSON.stringifydownstream. - Ref-counted document singleton — one
getBattery()call and one set of four listeners serve every consumer on the page; the manager is cached across a drop to zero subscribers, but the snapshot is re-read on re-subscribe because nothing was listening for the events that changed it. - Hand-written snapshots as a first-class input — exporting the pure derivation means the states this machine cannot produce (unsupported, blocked, 8% and draining) are designable and testable, which is the difference between a battery UI that was reasoned about and one that was only ever seen plugged in.
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.
useVibrate
A Vibration API hook that plays a normalized haptic pattern from a user gesture, names every refusal instead of failing silently, honours a page-wide haptics mute, and cancels the pattern it owns on unmount.