Feedback & Data
BatteryGauge
The battery is a real wet cell on a bench: a glass jar of acid with lead plates, brass posts and a cloth wire to a wall socket. The acid rises and turns green with the charge, gas bubbles off the plates, and it sloshes when you move the pointer or pull the plug.
Download
- React
- Tailwind CSS v4
- motion
components/ui/battery-gauge.tsx
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { motion, useAnimationFrame, useInView, useMotionValue, useReducedMotion, useSpring, useTransform } from "motion/react";
type BatteryGaugeProps = {
/** Charge, 0 to 100 (controlled). Leave out and it runs by itself. */
value?: number;
/** Charge at start (uncontrolled). */
defaultValue?: number;
/** Plugged in (controlled). */
charging?: boolean;
/** Plugged in at start (uncontrolled). */
defaultCharging?: boolean;
/** Percent per second while plugged in. It drains at a third of this. */
rate?: number;
/** At or below this it reads Low. */
low?: number;
/** Called when the plug is pulled or pushed in. */
onPlug?: (charging: boolean) => void;
/** Set the width here. Everything scales with it. */
className?: string;
};
const JAR = { left: 78, right: 282, floor: 214, top: 62 };
const PLATES = [108, 136, 164, 192, 220, 248];
const BUBBLES = Array.from({ length: 16 }, (_, i) => ({ x: PLATES[i % 6] + 22 - (i % 2) * 8 + (i % 3) * 2, speed: 26 + ((i * 13) % 22), r: 1.3 + (i % 3) * 0.6, start: (i * 41) % 100 }));
/* BatteryGauge: the battery is a real wet cell on a bench. A glass jar of acid with lead plates hanging in it, brass posts and a cloth wire to a wall socket. The acid rises and goes from red to green with the charge, gas bubbles off the plates while it charges, and the acid sloshes when you move the pointer or pull the plug. */
export function BatteryGauge({ value, defaultValue = 34, charging, defaultCharging = true, rate = 3, low = 15, onPlug, className = "w-full max-w-lg" }: BatteryGaugeProps) {
const reduce = useReducedMotion();
const id = useId().replace(/:/g, "");
const box = useRef<HTMLDivElement>(null);
const inView = useInView(box, { amount: 0.1 });
const [innerOn, setInnerOn] = useState(defaultCharging);
const on = charging ?? innerOn;
const [shown, setShown] = useState(value ?? defaultValue);
const [sparks, setSparks] = useState(0);
const level = value ?? shown;
const charge = useRef(value ?? defaultValue);
const slosh = useRef({ tilt: 0, speed: 0, x: 0 });
const liquid = useRef<SVGPathElement>(null);
const bubbleEls = useRef<(SVGCircleElement | null)[]>([]);
const fill = useMotionValue(level);
const smooth = useSpring(fill, reduce ? { stiffness: 10000, damping: 500 } : { stiffness: 60, damping: 16 });
useEffect(() => fill.set(level), [fill, level]);
const acid = useTransform(smooth, [0, 15, 40, 75, 100], ["#c8452f", "#c8452f", "#d99a2b", "#7fb446", "#4fbf78"]);
const plug = useSpring(on ? 1 : 0, reduce ? { stiffness: 10000, damping: 500 } : { stiffness: 260, damping: 18 });
useEffect(() => plug.set(on ? 1 : 0), [plug, on]);
const jackX = useTransform(plug, [0, 1], [-34, 0]);
const wire = useTransform(jackX, (x) => `M115 26 C 115 -20, 330 -6, 336 70 S ${346 + x} 158, ${360 + x} 158`);
useAnimationFrame((time, delta) => {
if (!inView) return;
const dt = Math.min(delta, 50) / 1000;
const t = time / 1000;
if (value === undefined) {
const next = Math.min(100, Math.max(0, charge.current + (on ? rate : -rate / 3) * dt));
charge.current = next;
if (Math.round(next) !== shown) setShown(Math.round(next));
}
// Spring for the tilt of the acid.
const s = slosh.current;
s.speed += (-30 * s.tilt - 2.4 * s.speed) * dt;
s.tilt += s.speed * dt;
const top = JAR.floor - (smooth.get() / 100) * (JAR.floor - JAR.top);
let d = "";
for (let x = JAR.left; x <= JAR.right; x += 6) {
const u = (x - 180) / 102;
const wave = reduce ? 0 : Math.sin(x / 22 + t * 2.2) * 1.3 + Math.sin(x / 9 - t * 3.1) * Math.min(2, Math.abs(s.speed) * 1.2);
d += `${d ? "L" : "M"}${x} ${(top + (reduce ? 0 : s.tilt * u * 15) + wave).toFixed(1)}`;
}
liquid.current?.setAttribute("d", `${d}L${JAR.right} ${JAR.floor + 4}L${JAR.left} ${JAR.floor + 4}Z`);
BUBBLES.forEach((b, i) => {
const el = bubbleEls.current[i];
if (!el) return;
const span = Math.max(1, JAR.floor - top);
const y = JAR.floor - 6 - ((b.start + t * b.speed) % span);
el.setAttribute("cy", y.toFixed(1));
el.setAttribute("cx", (b.x + Math.sin(t * 3 + i) * 1.6).toFixed(1));
el.setAttribute("opacity", on && !reduce ? String(Math.min(1, (y - top) / 8) * 0.8) : "0");
});
});
const toggle = () => {
const next = !on;
setInnerOn(next);
setSparks((n) => n + 1);
slosh.current.speed += next ? 1.3 : -1.3;
onPlug?.(next);
};
const status = level >= 100 ? "Full" : level <= low ? "Low" : on ? "Charging" : "Draining";
const mark = status === "Full" ? "โ" : status === "Low" ? "!" : on ? "โฒ" : "โผ";
return (
<div
ref={box}
className={`relative select-none ${className}`}
onPointerMove={(e) => {
const s = slosh.current;
s.speed = Math.max(-2.2, Math.min(2.2, s.speed + (e.clientX - s.x) * 0.012));
s.x = e.clientX;
}}
>
<svg viewBox="0 0 460 270" className="block h-auto w-full" role="meter" aria-label="Battery charge" aria-valuemin={0} aria-valuemax={100} aria-valuenow={level} aria-valuetext={`${level} percent, ${status}`}>
<defs>
<linearGradient id={`${id}glass`} x1="0" x2="1">
<stop offset="0" stopColor="#fff" stopOpacity="0.2" />
<stop offset="0.12" stopColor="#fff" stopOpacity="0.04" />
<stop offset="0.86" stopColor="#fff" stopOpacity="0.02" />
<stop offset="1" stopColor="#fff" stopOpacity="0.16" />
</linearGradient>
<linearGradient id={`${id}brass`} x1="0" x2="1">
<stop offset="0" stopColor="#7c5a22" />
<stop offset="0.45" stopColor="#e9c978" />
<stop offset="1" stopColor="#8a6529" />
</linearGradient>
<linearGradient id={`${id}wood`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#7a4f2c" />
<stop offset="1" stopColor="#45291a" />
</linearGradient>
<pattern id={`${id}grid`} width="5" height="5" patternUnits="userSpaceOnUse">
<rect width="5" height="5" fill="#5d6268" />
<path d="M0 0H5M0 0V5" stroke="#3d4146" strokeWidth="1" />
</pattern>
<clipPath id={`${id}jar`}>
<rect x={JAR.left} y={JAR.top - 16} width={JAR.right - JAR.left} height={JAR.floor - JAR.top + 22} rx="9" />
</clipPath>
</defs>
{/* Socket on the wall. */}
<rect x="372" y="118" width="62" height="80" rx="7" fill="#d9d3c2" stroke="#8e8876" strokeWidth="1.5" />
<circle cx="378" cy="124" r="1.6" fill="#8e8876" />
<circle cx="428" cy="192" r="1.6" fill="#8e8876" />
<circle cx="391" cy="158" r="13" fill="#b4ad99" />
<circle cx="391" cy="158" r="5.5" fill="#1a1816" />
<text x="403" y="185" textAnchor="middle" fontSize="11" fontFamily="ui-monospace, Consolas, monospace" fill="#4d4839">{on ? "ON" : "OFF"}</text>
{/* Bench. */}
<rect x="26" y="222" width="340" height="38" rx="5" fill={`url(#${id}wood)`} />
<path d="M34 232c60 3 110-3 160 1s90 2 164-1M38 248c70-3 120 2 170-1s80 1 150-1" stroke="#2d190e" strokeWidth="1" fill="none" opacity="0.5" />
{/* Lead plates. */}
{PLATES.map((x) => (
<rect key={x} x={x} y="46" width="16" height="158" fill={`url(#${id}grid)`} />
))}
{/* Acid. */}
<g clipPath={`url(#${id}jar)`}>
<motion.path ref={liquid} fill={acid} opacity="0.72" d={`M${JAR.left} ${JAR.floor}H${JAR.right}V${JAR.floor + 4}H${JAR.left}Z`} />
{BUBBLES.map((b, i) => (
<circle
key={i}
ref={(el) => {
bubbleEls.current[i] = el;
}}
r={b.r}
cx={b.x}
cy={JAR.floor}
fill="#fff"
opacity="0"
/>
))}
</g>
{/* Jar. */}
<rect x={JAR.left - 6} y="44" width={JAR.right - JAR.left + 12} height="178" rx="11" fill={`url(#${id}glass)`} stroke="#d7e4e8" strokeOpacity="0.55" strokeWidth="2.5" />
<path d="M84 62V204" stroke="#fff" strokeOpacity="0.3" strokeWidth="3" strokeLinecap="round" />
{/* Lid and posts. */}
<rect x="62" y="34" width="236" height="20" rx="4" fill="#1d1a18" />
<rect x="62" y="34" width="236" height="4" rx="2" fill="#fff" opacity="0.1" />
<rect x="108" y="22" width="14" height="14" rx="2" fill={`url(#${id}brass)`} />
<rect x="238" y="22" width="14" height="14" rx="2" fill={`url(#${id}brass)`} />
<path d="M100 26h30l-5 4h-20zM230 26h30l-5 4h-20z" fill={`url(#${id}brass)`} />
<circle cx="115" cy="45" r="4.5" fill="#b8392b" />
<circle cx="245" cy="45" r="4.5" fill="#39393b" stroke="#6b6b6e" strokeWidth="1" />
<text x="115" y="48" textAnchor="middle" fontSize="8" fill="#fff">+</text>
<text x="245" y="48" textAnchor="middle" fontSize="8" fill="#fff">-</text>
<text x="180" y="48" textAnchor="middle" fontSize="9" letterSpacing="2" fontFamily="ui-monospace, Consolas, monospace" fill="#8b8580">6 CELL</text>
{/* Wire and jack. */}
<motion.path d={wire} fill="none" stroke="#2c211a" strokeWidth="6" strokeLinecap="round" />
<motion.path d={wire} fill="none" stroke="#b5873f" strokeWidth="2" strokeDasharray="3 6" strokeLinecap="round" />
<motion.g style={{ x: jackX }}>
<rect x="356" y="150" width="20" height="16" rx="3" fill="#1d1a18" />
<rect x="376" y="154" width="15" height="8" rx="1" fill={`url(#${id}brass)`} />
</motion.g>
{sparks > 0 && !reduce && (
<motion.g key={sparks} initial={{ opacity: 1, scale: 0.4 }} animate={{ opacity: 0, scale: 1.2 }} transition={{ duration: 0.35 }} style={{ transformOrigin: "386px 158px", transformBox: "view-box" }}>
{[-70, -30, 10, 50, 100, 150].map((a) => (
<line key={a} x1={386 + Math.cos((a * Math.PI) / 180) * 8} y1={158 + Math.sin((a * Math.PI) / 180) * 8} x2={386 + Math.cos((a * Math.PI) / 180) * 22} y2={158 + Math.sin((a * Math.PI) / 180) * 22} stroke="#ffe08a" strokeWidth="2" strokeLinecap="round" />
))}
</motion.g>
)}
{/* Brass plate with the reading. */}
<rect x="96" y="229" width="168" height="26" rx="3" fill={`url(#${id}brass)`} stroke="#5d4418" strokeWidth="1" />
<text x="110" y="248" fontSize="19" fontWeight="700" fontFamily="ui-monospace, Consolas, monospace" fill="#2b2008">{level}%</text>
<text x="252" y="247" textAnchor="end" fontSize="13" fontFamily="ui-monospace, Consolas, monospace" fill="#2b2008">{mark} {status}</text>
</svg>
<button
type="button"
onClick={toggle}
aria-pressed={on}
aria-label={on ? "Pull the plug" : "Plug in"}
className="absolute cursor-pointer rounded-[8%]"
style={{ left: "72%", top: "42%", width: "23%", height: "32%" }}
/>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/battery-gauge.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/battery-gauge-demo.tsx
import { BatteryGauge } from "@/components/ui/battery-gauge"; export default function BatteryGaugeExample() { return ( <div className="@container-size grid h-full place-items-center overflow-hidden bg-[#2d3a3e] p-2"> <BatteryGauge className="w-[min(165cqh,96cqw,560px)]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| value | number | - | Controlled charge, 0 to 100. Leave out and it runs by itself. |
| defaultValue | number | 34 | Uncontrolled start charge. |
| charging | boolean | - | Controlled plug state. |
| defaultCharging | boolean | true | Plugged in at start. |
| rate | number | 3 | Percent per second while plugged in. It drains at a third of this. |
| low | number | 15 | At or below this it reads Low. |
| onPlug | (charging: boolean) => void | - | Called when the plug is pulled or pushed in. |
| className | string | "w-full max-w-lg" | Set the width here. Everything scales with it. |