Hooks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/**
 * Battery Status API 的最小结构声明。TypeScript 的 `lib.dom` **不提供**
 * `BatteryManager`,`navigator.getBattery` 也不挂在 `Navigator` 上 —— 这个规范
 * Firefox 在 52 版移除、Safari 从未实现,如今只剩 Chromium 系还在跑。所以形状在
 * 本地声明,并用 `as unknown as` 过桥:既不依赖消费者的 lib 版本,也不会跟某些
 * 第三方 d.ts 里自带的声明撞类型。
 */
interface BatteryManagerLike extends EventTarget {
  /** 设备当前是否在充电。 */
  readonly charging: boolean

Installation

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

Prompt

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: false throws 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 Infinity on both time fields. Normalising to null at the boundary is what stops Infinity from reaching arithmetic, toFixed, or JSON.stringify downstream.
  • 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.

On This Page