Battery Status
A live battery readout on the Battery Status API: animated fill, color by charge, a charging bolt, and time-to-full. Falls back to a simulation where the API is missing.
Reading battery…
live on Chromium · simulated on Safari & Firefox
Usage
import { BatteryStatus } from "~/components/battery/battery-status";<BatteryStatus />
Or just the useBattery hook, if you want the values without this UI:
import { useBattery } from "~/hooks/use-battery";function Indicator() {const status = useBattery();if (status.kind !== "live") return null;return <span>{Math.round(status.battery.level * 100)}%</span>;}
One promise, four events
navigator.getBattery() resolves to a battery object with four properties: level (0 to 1), charging, and the chargingTime / dischargingTime estimates in seconds. The object also fires events when any of those change, so you read once up front and then just re-read on each event. No polling.
The catch is support. It only ships in Chromium. Firefox and Safari removed it years ago over fingerprinting concerns, since a precise charge level plus discharge time is a surprisingly good device identifier. So feature-detect getBattery and have a plan for when it's missing. This component animates a simulated battery there, labelled as such, so the widget never just shows an error.
if ("getBattery" in navigator) {const battery = await navigator.getBattery();const read = () => render({level: battery.level, // 0..1charging: battery.charging, // booleanchargingTime: battery.chargingTime, // seconds, or InfinitydischargingTime: battery.dischargingTime, // seconds, or Infinity});read();battery.addEventListener("levelchange", read);battery.addEventListener("chargingchange", read);}
Props
| prop | type | default | description |
|---|---|---|---|
| simulateWhenUnsupported | boolean | true | Animate a fake battery where the API is missing (Safari, Firefox) instead of showing a bare message. |
| className | string | — | Extra classes on the card. |
Inspired by Bilal Hussain.
Source
download .zipThe full implementation, across 3 files. Copy it in or grab the zip. It leans on a cn() class helper and the theme tokens (--primary, --border, …).
import { Zap } from "lucide-react";import { cn } from "~/lib/utils";import { useBattery } from "~/hooks/use-battery";/*** Live battery readout on the Battery Status API (navigator.getBattery).* Chromium-only; where the API is missing (Safari, Firefox) it falls back to a* gently animated simulation so the widget still reads. Built on the* `useBattery` hook. Inspired by Bilal Hussain (@BilliCodes).*/export function BatteryStatus({simulateWhenUnsupported = true,className,}: {simulateWhenUnsupported?: boolean;className?: string;}) {const status = useBattery({ simulateWhenUnsupported });if (status.kind === "loading") {return (<div className={cn("battery-card", className)}><p className="text-sm text-[hsl(var(--muted-foreground))]">Reading battery…</p></div>);}if (status.kind === "unsupported") {return (<div className={cn("battery-card", className)}><p className="text-sm text-[hsl(var(--muted-foreground))]">The Battery Status API isn't available in this browser.</p></div>);}const { battery } = status;const pct = Math.round(battery.level * 100);const color =battery.level > 0.5? "hsl(142 71% 45%)": battery.level > 0.2? "hsl(38 92% 50%)": "hsl(0 84% 60%)";const eta =battery.charging && battery.chargingTime !== Infinity && pct < 100? `full in ${fmt(battery.chargingTime)}`: !battery.charging && battery.dischargingTime !== Infinity? `${fmt(battery.dischargingTime)} left`: battery.charging? pct >= 100? "fully charged": "charging": "on battery";return (<div className={cn("battery-card", className)}><div className="flex items-center gap-5">{/* battery glyph */}<divclassName={cn("battery-glyph", battery.charging && "is-charging")}style={{ "--lvl": `${pct}%`, "--clr": color } as React.CSSProperties}><div className="battery-fill" />{battery.charging && (<Zap className="battery-bolt" fill="currentColor" strokeWidth={0} />)}<span className="battery-cap" /></div><div><div className="flex items-baseline gap-2"><spanclassName="font-mono text-4xl font-semibold tabular-nums"style={{ color }}>{pct}%</span>{battery.charging && (<span className="flex items-center gap-1 text-sm font-medium text-[hsl(142_71%_45%)]"><Zap className="size-3.5" fill="currentColor" strokeWidth={0} />charging</span>)}</div><p className="mt-1 text-sm text-[hsl(var(--muted-foreground))] first-letter:uppercase">{eta}</p></div></div>{status.kind === "simulated" && (<p className="mt-4 border-t border-[hsl(var(--border))] pt-3 font-mono text-xs text-[hsl(var(--muted-foreground))]">simulated — your browser doesn't expose the Battery API</p>)}</div>);}function fmt(seconds: number): string {const m = Math.round(seconds / 60);if (m < 60) return `${m}m`;const h = Math.floor(m / 60);const rem = m % 60;return rem ? `${h}h ${rem}m` : `${h}h`;}
import { useEffect, useRef, useState } from "react";export interface BatteryState {level: number; // 0..1charging: boolean;chargingTime: number; // seconds, or InfinitydischargingTime: number; // seconds, or Infinity}export type BatteryStatus =| { kind: "loading" }| { kind: "live"; battery: BatteryState }| { kind: "simulated"; battery: BatteryState }| { kind: "unsupported" };interface NavigatorBattery {level: number;charging: boolean;chargingTime: number;dischargingTime: number;addEventListener: (t: string, fn: () => void) => void;removeEventListener: (t: string, fn: () => void) => void;}/*** Subscribe to the Battery Status API (navigator.getBattery). Returns a tagged* status: "loading" until the promise resolves, then "live" with the battery* values (re-emitted on every change event). Chromium-only — where the API is* missing (Safari, Firefox) it returns "unsupported", or a gently animated* "simulated" status when `simulateWhenUnsupported` is true (the default).** const status = useBattery();* if (status.kind === "live") console.log(status.battery.level);*/export function useBattery({simulateWhenUnsupported = true,}: { simulateWhenUnsupported?: boolean } = {}): BatteryStatus {const [status, setStatus] = useState<BatteryStatus>({ kind: "loading" });const simRef = useRef<number | null>(null);useEffect(() => {let cancelled = false;let battery: (NavigatorBattery & { __read?: () => void }) | null = null;const getBattery = (navigator as unknown as {getBattery?: () => Promise<NavigatorBattery>;}).getBattery;if (typeof getBattery === "function") {getBattery.call(navigator).then((b) => {if (cancelled) return;const read = () =>setStatus({kind: "live",battery: {level: b.level,charging: b.charging,chargingTime: b.chargingTime,dischargingTime: b.dischargingTime,},});read();b.addEventListener("levelchange", read);b.addEventListener("chargingchange", read);b.addEventListener("chargingtimechange", read);b.addEventListener("dischargingtimechange", read);battery = Object.assign(b, { __read: read });});return () => {cancelled = true;if (battery?.__read) {battery.removeEventListener("levelchange", battery.__read);battery.removeEventListener("chargingchange", battery.__read);battery.removeEventListener("chargingtimechange", battery.__read);battery.removeEventListener("dischargingtimechange", battery.__read);}};}// No Battery API (Safari / Firefox).if (!simulateWhenUnsupported) {setStatus({ kind: "unsupported" });return;}let level = 0.18;let charging = true;const tick = () => {level += charging ? 0.01 : -0.008;if (level >= 1) {level = 1;charging = false;} else if (level <= 0.1) {level = 0.1;charging = true;}setStatus({kind: "simulated",battery: {level,charging,chargingTime: charging ? (1 - level) * 3600 : Infinity,dischargingTime: charging ? Infinity : level * 9000,},});};tick();simRef.current = window.setInterval(tick, 700);return () => {if (simRef.current) window.clearInterval(simRef.current);};}, [simulateWhenUnsupported]);return status;}
/* ── Battery status ──────────────────────────────────────────────────────── */.battery-card {border: 1px solid hsl(var(--border));background: hsl(var(--card));border-radius: 14px;padding: 24px 28px;min-width: 260px;}.battery-glyph {position: relative;width: 84px;height: 40px;border: 2.5px solid hsl(var(--foreground) / 0.55);border-radius: 9px;padding: 4px;flex: none;}.battery-cap {position: absolute;right: -8px;top: 50%;transform: translateY(-50%);width: 6px;height: 16px;border-radius: 0 3px 3px 0;background: hsl(var(--foreground) / 0.55);}.battery-fill {height: 100%;width: var(--lvl, 0%);border-radius: 4px;background: var(--clr, hsl(var(--primary)));transition: width 0.6s cubic-bezier(0.2, 0.7, 0.2, 1), background 0.4s ease;}.battery-glyph.is-charging .battery-fill {animation: battery-pulse 1.8s ease-in-out infinite;}.battery-bolt {position: absolute;inset: 0;margin: auto;width: 22px;height: 22px;color: hsl(0 0% 100%);filter: drop-shadow(0 0 3px hsl(0 0% 0% / 0.4));}@keyframes battery-pulse {0%, 100% { filter: brightness(1); }50% { filter: brightness(1.25); }}@media (prefers-reduced-motion: reduce) {.battery-glyph.is-charging .battery-fill { animation: none; }.battery-fill { transition: none; }}