stream/ui
Browse the library

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/react
Open in v0 (opens in a new tab)
usage.tsx
import { Battery } from "@streamuilib/react";

<Battery value={72} charging />

API

PropTypeDefaultDescription
valuenumber—Charge from 0 to 100. Clamped and rounded.
chargingbooleanfalseShows 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.
lowAtnumber20At or below this the battery reads as low, and blinks unless charging.
segmentsnumber—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.
showValuebooleantrueShow the percentage next to the battery.
labelstring"Battery"Accessible name.
...propsComponentProps<"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>
  );
}