Skip to content
← components

Battery Status

new

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

example.tsx
import { BatteryStatus } from "~/components/battery/battery-status";
<BatteryStatus />

Or just the useBattery hook, if you want the values without this UI:

use-battery.tsx
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.

battery.ts
if ("getBattery" in navigator) {
const battery = await navigator.getBattery();
const read = () => render({
level: battery.level, // 0..1
charging: battery.charging, // boolean
chargingTime: battery.chargingTime, // seconds, or Infinity
dischargingTime: battery.dischargingTime, // seconds, or Infinity
});
read();
battery.addEventListener("levelchange", read);
battery.addEventListener("chargingchange", read);
}

Props

proptypedefaultdescription
simulateWhenUnsupportedbooleantrueAnimate a fake battery where the API is missing (Safari, Firefox) instead of showing a bare message.
classNamestringExtra classes on the card.

Inspired by Bilal Hussain.

The 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, …).

components/battery/battery-status.tsx
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 */}
<div
className={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">
<span
className="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`;
}
hooks/use-battery.ts
import { useEffect, useRef, useState } from "react";
export interface BatteryState {
level: number; // 0..1
charging: boolean;
chargingTime: number; // seconds, or Infinity
dischargingTime: 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;
}
app/styles/app.css
/* ── 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; }
}