Comic Battery
A hand-inked battery that drains from lime to pink, with a ZAP! when charging and a shaking LOW!
betaComicBattery.tsx
Examples
Levels and states
100%
46%
12%
72%
comic-battery/levels.tsx
import { ComicBattery } from "@streamuilib/react";
export default function ComicBatteryLevels() {
return (
<div className="flex flex-wrap items-center gap-x-12 gap-y-10 pt-6 pl-4">
<ComicBattery value={100} />
<ComicBattery value={46} />
<ComicBattery value={12} label="Low battery" />
<ComicBattery value={72} charging />
</div>
);
}Interactive
Drag the charge and plug it in.
35%
comic-battery/interactive.tsx
"use client";
import { useState } from "react";
import { ComicBattery, ComicSlider, ComicSwitch } from "@streamuilib/react";
export default function ComicBatteryInteractive() {
const [level, setLevel] = useState(35);
const [charging, setCharging] = useState(false);
return (
<div className="flex w-full max-w-sm flex-col items-start gap-6 pt-4">
<ComicBattery value={level} charging={charging} size="lg" />
<label className="flex w-full flex-col gap-1">
<span className="font-bold">Charge</span>
<ComicSlider value={level} onValueChange={setLevel} tone="orange" formatValue={(v) => `${v}%`} />
</label>
<label className="flex items-center gap-3 font-bold">
<ComicSwitch checked={charging} onCheckedChange={setCharging} /> Plugged in
</label>
</div>
);
}Installation
bun add @streamuilib/reactusage.tsx
import { ComicBattery } from "@streamuilib/react";
<ComicBattery value={72} charging />API
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number | — | Charge from 0 to 100. Clamped. |
| charging | boolean | false | Races stripes across the fill and slaps on a ZAP! burst. |
| tone | "auto" | "lime" | "pink" | "blue" | "violet" | "orange" | "auto" | Fill color; auto goes orange at half and pink when low. |
| lowAt | number | 20 | At or below this the battery is low and shows LOW! |
| size | "sm" | "md" | "lg" | "md" | Battery size. |
| showValue | boolean | true | Show the percentage beside it. |
| sfx | [ReactNode, ReactNode] | false | ["Zap!", "Low!"] | The charging and low stickers; false for none. |
| label | string | "Battery" | Accessible name. |
Accessibility
- Uses role="meter" with aria-valuetext that reads the state, for example "12%, low", like Battery.
- The stickers are decorative; the state is in the accessible value and the percentage.
- The stripes, zap, and shake stop for reduced motion.
Source
packages/react/src/ComicBattery.tsx
import type { ReactNode } from "react";
import { cn } from "./cn";
import { halftone } from "./comic";
import { ComicBadge } from "./ComicBadge";
import type { BatteryProps } from "./Battery";
import { toneBg, type Tone } from "./tones";
export type ComicBatteryProps = Omit<BatteryProps, "segments"> & {
/** Stickers for the states: charging then low. Defaults to ZAP! / LOW!; false for none. */
sfx?: [ReactNode, ReactNode] | false;
};
const body = { sm: "h-6 w-12", md: "h-9 w-18", lg: "h-12 w-24" } as const;
const cap = { sm: "h-2.5 w-1.5", md: "h-4 w-2", lg: "h-5 w-2.5" } as const;
const text = { sm: "text-xs", md: "text-sm", lg: "text-lg" } as const;
function autoTone(percent: number, lowAt: number): Tone {
if (percent <= lowAt) return "pink";
if (percent <= 50) return "orange";
return "lime";
}
/**
* A comic-book battery: a hand-inked body with halftone dots and a fill that
* goes lime, orange, then pink as it drains. Charging slaps on a ZAP!
* starburst and races stripes across the fill; a low battery shakes a LOW!
* sticker. role="meter", the same as Battery. Motion stops for reduced motion.
*/
export function ComicBattery({
value,
charging = false,
tone = "auto",
lowAt = 20,
size = "md",
showValue = true,
label = "Battery",
sfx = ["Zap!", "Low!"],
className,
...props
}: ComicBatteryProps) {
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 state = [charging && "charging", low && "low"].filter(Boolean).join(", ");
const sticker = sfx ? (charging ? sfx[0] : low ? sfx[1] : null) : null;
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-comic-battery inline-flex items-center gap-3 text-ink", className)}
{...props}
>
<span aria-hidden className="relative inline-flex -rotate-2 items-center">
<span
className={cn(
"relative block overflow-hidden rounded-[8px_4px_9px_4px/4px_9px_4px_8px] border-[3px] border-ink bg-paper-sunken p-[3px] shadow-[3px_3px_0_var(--ink)]",
body[size],
)}
style={halftone(16)}
>
<span
className={cn("sui-battery-fill block h-full rounded-[3px] transition-[width] duration-300 ease-out motion-reduce:transition-none", fill)}
style={{ width: `${percent}%` }}
/>
</span>
<span className={cn("block rounded-r-[3px] border-[3px] border-l-0 border-ink bg-ink", cap[size])} />
{sticker && (
<span className={cn("sui-comic-battery-sfx absolute -top-8 -left-7 z-10")}>
<ComicBadge shape={charging ? "burst" : "splat"} tone={charging ? "orange" : "pink"} size="sm" tilt={charging ? 10 : -8} className="size-12 p-2">
{sticker}
</ComicBadge>
</span>
)}
</span>
{showValue && <span className={cn("font-mono font-bold tabular-nums", text[size])}>{percent}%</span>}
</div>
);
}