Battery
A battery level that changes color as it drains, with charging and low states.
betaBattery.tsx
Examples
Levels
With tone="auto" (the default) it goes orange at half and pink when low; low ones blink.
100%
64%
38%
12%
72%
battery/levels.tsx
import { Battery } from "@streamuilib/react";
export default function BatteryLevels() {
return (
<div className="flex flex-wrap items-center gap-x-8 gap-y-5">
<Battery value={100} />
<Battery value={64} />
<Battery value={38} />
<Battery value={12} label="Low battery" />
<Battery value={72} charging />
</div>
);
}Segments, sizes, and tones
80%
55%
18%
90%
30%
battery/segments.tsx
import { Battery } from "@streamuilib/react";
export default function BatterySegments() {
return (
<div className="flex flex-col gap-5">
<div className="flex flex-wrap items-center gap-6">
<Battery value={80} segments={4} size="sm" />
<Battery value={55} segments={5} />
<Battery value={18} segments={5} size="lg" />
</div>
<div className="flex flex-wrap items-center gap-6">
<Battery value={90} tone="blue" />
<Battery value={45} tone="violet" showValue={false} label="Controller battery" />
<Battery value={30} tone="pink" segments={3} charging />
</div>
</div>
);
}Interactive
42%
battery/interactive.tsx
"use client";
import { useState } from "react";
import { Battery, Slider, Switch } from "@streamuilib/react";
export default function BatteryInteractive() {
const [level, setLevel] = useState(42);
const [charging, setCharging] = useState(false);
return (
<div className="flex w-full max-w-xs flex-col gap-5">
<Battery value={level} charging={charging} size="lg" label="Phone battery" />
<label className="flex flex-col gap-2 font-bold">
Level
<Slider value={level} onValueChange={setLevel} tone="lime" aria-label="Battery level" />
</label>
<label className="flex items-center justify-between gap-3 font-bold">
Charging
<Switch checked={charging} onCheckedChange={setCharging} aria-label="Charging" />
</label>
</div>
);
}Installation
bun add @streamuilib/reactusage.tsx
import { Battery } from "@streamuilib/react";
<Battery value={72} charging />API
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number | — | Charge from 0 to 100. Clamped and rounded. |
| charging | boolean | false | Shows a bolt and sweeps stripes across the fill. |
| tone | "auto" | "lime" | "pink" | "blue" | "violet" | "orange" | "auto" | Fill color. auto is pink at or below lowAt, orange at 50 or below, and lime above. |
| lowAt | number | 20 | At or below this the battery reads as low, and blinks unless charging. |
| segments | number | — | Discrete cells instead of a smooth fill, lit to the nearest cell. Any charge lights at least one. |
| size | "sm" | "md" | "lg" | "md" | Battery and label size. |
| showValue | boolean | true | Show the percentage next to the battery. |
| label | string | "Battery" | Accessible name. |
| ...props | ComponentProps<"div"> | — | Native attributes. |
Accessibility
- Uses role="meter" with aria-valuenow, aria-valuemin, and aria-valuemax, named by label.
- aria-valuetext reads the state too, for example "12%, low" or "72%, charging".
- Charging and low are shown with a bolt and the percentage as well as color and motion.
- The charging stripes and the low blink stop for reduced motion.
Source
packages/react/src/Battery.tsx
import type { ComponentProps } from "react";
import { Zap } from "lucide-react";
import { cn } from "./cn";
import { toneBg, type Tone } from "./tones";
export type BatterySize = "sm" | "md" | "lg";
export type BatteryProps = Omit<ComponentProps<"div">, "children"> & {
/** Charge from 0 to 100. Clamped. */
value: number;
/** Shows a bolt and sweeps stripes across the fill. */
charging?: boolean;
/** Fill color. "auto" goes pink when low, orange at half or less, lime above. */
tone?: Tone | "auto";
/** At or below this, the battery counts as low: it reads "low" and blinks unless charging. */
lowAt?: number;
/** Discrete cells instead of a smooth fill, like a phone's status bar. */
segments?: number;
size?: BatterySize;
/** Show the percentage next to the battery. */
showValue?: boolean;
/** Accessible name. */
label?: string;
};
const body: Record<BatterySize, string> = {
sm: "h-5 w-10",
md: "h-7 w-14",
lg: "h-10 w-20",
};
const cap: Record<BatterySize, string> = {
sm: "h-2 w-1",
md: "h-3 w-1.5",
lg: "h-4 w-2",
};
const bolt: Record<BatterySize, string> = {
sm: "size-3.5",
md: "size-4.5",
lg: "size-6",
};
const text: Record<BatterySize, string> = {
sm: "text-xs",
md: "text-sm",
lg: "text-lg",
};
function autoTone(percent: number, lowAt: number): Tone {
if (percent <= lowAt) return "pink";
if (percent <= 50) return "orange";
return "lime";
}
/**
* A battery level: a hard-edged body and cap, a fill that changes color as
* it drains, and the percentage beside it. Charging adds a bolt and moving
* stripes; a low battery blinks. Both hold still for reduced motion.
*/
export function Battery({
value,
charging = false,
tone = "auto",
lowAt = 20,
segments,
size = "md",
showValue = true,
label = "Battery",
className,
...props
}: BatteryProps) {
const percent = Math.round(Math.min(100, Math.max(0, value)));
const low = percent <= lowAt;
const fill = toneBg[tone === "auto" ? autoTone(percent, lowAt) : tone];
const cells = segments && segments > 0 ? Math.floor(segments) : 0;
// Nearest cell, but a sliver of charge still lights one.
const litCells = cells && percent > 0 ? Math.max(1, Math.round((percent / 100) * cells)) : 0;
const state = [charging && "charging", low && "low"].filter(Boolean).join(", ");
return (
<div
role="meter"
aria-label={label}
aria-valuenow={percent}
aria-valuemin={0}
aria-valuemax={100}
aria-valuetext={state ? `${percent}%, ${state}` : `${percent}%`}
data-charging={charging || undefined}
data-low={(low && !charging) || undefined}
className={cn("sui-battery inline-flex items-center gap-2 text-ink", className)}
{...props}
>
<span aria-hidden className="inline-flex items-center">
<span className={cn("relative block border-2 border-ink bg-paper-sunken p-0.5 shadow-brutal-sm", body[size])}>
{cells ? (
<span className="flex size-full gap-0.5">
{Array.from({ length: cells }, (_, i) => (
<span key={i} className={cn("sui-battery-fill h-full flex-1", i < litCells ? fill : "bg-transparent")} />
))}
</span>
) : (
<span
className={cn("sui-battery-fill block h-full transition-[width] duration-300 ease-out motion-reduce:transition-none", fill)}
style={{ width: `${percent}%` }}
/>
)}
{charging && (
<span className="absolute inset-0 grid place-items-center">
<Zap className={cn("fill-paper-raised stroke-ink", bolt[size])} strokeWidth={2.5} />
</span>
)}
</span>
<span className={cn("block border-2 border-l-0 border-ink bg-ink", cap[size])} />
</span>
{showValue && <span className={cn("font-mono font-bold tabular-nums", text[size])}>{percent}%</span>}
</div>
);
}