Battery slider
NewA check-in card whose slider charges a battery from red to green, with a bolt, a glow and soft synthesised ticks.
battery-slider/battery-slider.tsxThe live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- A short check-in about the body, asked one question at a time: “How are you feeling physically today?”.
- Answers about energy, rest or fatigue on a scale from drained to charged, where a battery says it faster than a number.
- A moment worth a little delight: the full battery charges under a bolt, glows and sparkles, so it suits screens seen once a day, not once a minute.
- Mobile-first surveys: one big thumb, one button, and the card scales as one drawing to any width.
- Next to the mood slider in the same check-in: the same card and motion, with batteries where it has faces.
When not to use
- A real charge level or any exact number someone must read back. Use a plain slider or a meter with its value shown.
- Questions about feelings rather than energy: the batteries read oddly there. Use the mood slider.
- Questions with named, unordered answers. Use radio buttons or a segmented control.
- Long forms or surveys of many questions: one card at a time and the animations slow them down. Use a form with plain inputs.
- Clinical assessments with a validated scale: the batteries and the colour fill lead the answer.
You can add this component with the shadcn CLI or by hand:
Into components/battery-slider of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/battery-slider.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { BatterySlider } from './battery-slider';export function EnergyCheckIn({ save }: { save: (answer: number) => void }) { return ( <BatterySlider questions={[{ title: 'How are you feeling physically today?' }]} onVote={(answer) => save(answer)} autoplay={false} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
A dark card that asks “How are you feeling physically today?” and takes the answer on a slider that starts in the middle, under three batteries: drained, half and full. Drag left and the track fills red to yellow as the drained battery grows, with a red ripple beating around it at the end. Drag right and the full battery empties to a sliver, then charges red to green with the thumb under a white bolt, glows when it is full and sends out a green ring and sparkles. Each step ticks, higher as it charges; VOTE blurs the question’s changing words away and brings the next one in.
- The thumb rides a damped spring (stiffness 560, damping 26, about 15% of bounce) stepped every 4 ms, so a fast drag into an end lands with a small bounce past it. Once per frame the spring writes a single custom property, --p, from 0 to 1, and stops when it settles.
- The thumb’s transform, the fill’s box and the batteries’ sizes are calc()s of --p in the CSS. The fill runs from the middle of the track to the thumb and wraps it 3.75 units around: one box with two gradients, red to yellow and yellow to green, whose opacities cross over in the 2.5% around the middle.
- Each battery sits where the thumb’s middle stops (the dots at the ends, and the middle) and scales from 0.72 of its size to full as the thumb comes within half the track of it.
- The batteries are 46 by 29 SVG drawings: a case and a cap in the scheme’s grey, a charge in a vertical gradient and a bolt. The drained one holds 11% in red, the half one 59% in orange. The full one is redrawn from --p’s number: in the first 4% past the middle its idle green charge drops to 28%, then fills to 100% with the thumb as its colour runs red-orange, orange, yellow, lime, green, under a white bolt that fades and scales in. Charged, the bolt goes and a green drop-shadow glows around it.
- Reaching the left end sends a red ripple out of the drained battery (1 to 1.42 times in 640 ms), again every 470 ms while a finger holds it there. Reaching the right end flashes a green ring out to 1.62 times in 900 ms and flings three four-pointed sparkles off it, with a fourth a moment later.
- The sound is synthesised with the Web Audio API, no files: a tick each time the value crosses a step of 5, a sine that drops in pitch over a breath of filtered noise, higher and louder as the battery charges. Reaching the drained end adds a soft thud that falls (260 to 143 Hz in 160 ms); the full end, two chimes a fifth apart, 75 ms after one another. The vote pops with a short rising blip. One AudioContext serves the page: it is made on the reader’s first press or key on the card, suspended 1.5 s after the last sound and closed with the last card; the demo never sounds.
- VOTE keeps the words the next question shares: the rest squash up into a blur in 180 ms, the counter’s digit rolls up, and the batteries shrink away 35 ms apart while the thumb glides back to the middle on a critically damped spring. The next words drop in from a blur and a clip in 320 ms; the new batteries pop in with a slight overshoot, left first.
- The question’s white-to-grey sweep is one gradient across the whole title, shared by its words: each word clips its own part of it, so the words can still move one by one.
- While on screen and untouched, the demo plays the questions with a touch mark for a finger: it holds the drained end for 1.5 s while the ripple beats, charges the battery all the way, then votes, without a sound. Any key or press hands it to the reader where it was.
- Every colour is a light-dark() token on the root, so the card follows the page’s color-scheme or the colorScheme prop. By day the card is white on pale grey and the batteries’ case a light grey; the fills and charges are the same on both.
| Prop | Type | Default | Description |
|---|---|---|---|
questions | { title: string; labels?: [string, string, string] }[] | two energy questions | The questions, asked in turn, and what the slider’s left, middle and right mean to screen readers (Drained, Okay, Charged by default). After the last vote it starts again at the first; with one question the counter is hidden. |
value | number | — | The current question’s answer, 0 to 100, for a controlled slider. |
defaultValue | number | 50 | Where the slider starts for each question. |
defaultQuestion | number | — | The question, by its index, the card opens on. Setting it turns the demo off. |
onChange | (value: number, index: number) => void | — | Called with the new answer and the question’s index as the reader moves the slider, and with the starting value and the next index after a vote. |
onVote | (value: number, index: number) => void | — | Called when the reader votes, with the answer and the question’s index. |
onComplete | (values: number[]) => void | — | Called after the last question’s vote with every answer. |
voteLabel | string | 'Vote' | The button’s words, shown in capitals. |
sound | boolean | true | Soft synthesised ticks as the value moves, a soft thud at the drained end, two chimes when it is full and a pop on VOTE, once the reader has pressed or keyed the card. The demo never makes a sound. |
autoplay | boolean | true | While on screen and untouched, the card plays the questions by itself, with a touch mark for a finger. |
colorScheme | 'light' | 'dark' | 'system' | — | Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one. |
className | string | — | Extra class for the root. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useEffectEvent, useId, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent,} from 'react';import { clickSound, type ClickSound } from './click-sound';import { Icon, morphIcon, SMALL, type Slot } from './icons';import { useAutoplay } from './use-autoplay';import styles from './battery-slider.module.css';/* * Battery slider: a check-in card that asks one question at a time ("How are you feeling * physically today?") and takes the answer on a slider that starts in the middle. Three batteries * sit above it, drained, half and full, at the places the thumb reaches at its ends and in its/* Battery slider (battery-slider.tsx): a check-in card on a stage. At night the stage is near-black and the card a dark grey; by day the card is white on a pale grey. Every size is in --u, a unit of the card, which is 382 units wide. The stage sets the unit from its own width (a container query), so the whole card scales as one drawing. Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). The fills and the charges are the same on both; only the batteries' case follows the scheme. Memory. The card is one layout boundary. The thumb moves by a transform; the fill's box and the batteries' sizes are calc()s of one custom property, --p, that the spring writes once per frame. *//* A length that stays the card's unit wherever it is read, even in other type sizes. */@property --u { syntax: '<length>'; inherits: true; initial-value: 1px;}.root { /* The stage, the card and its lift. */ --stage: #efefef;// Copyright (c) 2026 Bruno Delorence. MIT License./* * The three batteries above the slider: drained, half and full. Each is drawn once in SVG at its * full size; morphIcon() then reshapes the right one from the slider's place, once per frame while * the thumb moves: it leaves its idle full charge for one that fills red to green with the thumb, * under a bolt, and glows once it is full. */export type Slot = 0 | 1 | 2;/** How large a battery is when the thumb is far from it, against its size when the thumb is on it. */export const SMALL = 0.72;const clamp = (value: number) => Math.min(1, Math.max(0, value));const mix = (from: number, to: number, t: number) => from + (to - from) * t;const n = (value: number) => Math.round(value * 100) / 100;/** Hex colours mixed in RGB. */function blend(from: string, to: string, t: number) { const a = parseInt(from.slice(1), 16); const b = parseInt(to.slice(1), 16); const channel = (shift: number) => Math.round(mix((a >> shift) & 255, (b >> shift) & 255, t)); return `rgb(${channel(16)} ${channel(8)} ${channel(0)})`;/* * The slider's sounds, synthesised with the Web Audio API, no audio file. A tick is a sine blip * that drops in pitch, over a breath of filtered noise for its edge, shaped by an envelope a few * milliseconds long; higher values tick higher and louder. The ends and the vote are the same kind * of blip, a little longer: the low end a soft falling thud, the high end two rising chimes, the * vote a short pop that rises. * * Browsers allow sound only after a gesture of the reader's, so nothing plays before their first * press or key on the card. One AudioContext serves every card on the page: it is created on that * first gesture, suspended when the sounds stop (an idle context still runs its audio thread), and * closed when the last card is gone. *//** A tick's length, in seconds. */const LENGTH = 0.05;/** Ticks closer than this (ms) are skipped: a fast drag would buzz. */const MIN_GAP = 22;/** After this long without a sound (ms), the audio is suspended. */const REST = 1500;let context: AudioContext | null = null;let noise: AudioBuffer | null = null;let users = 0;let lastTick = 0;'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver( ([entry]) => { onScreen = entry.isIntersecting;- The track is a real slider (role="slider", 0 to 100), named by the question and read as “Charged, 90 of 100”. Arrows move it by 5, Page Up and Page Down by 20, Home and End to the ends, with a visible focus ring.
- A press anywhere on the track moves the thumb there; pressing the thumb itself grabs it where it was held. Vertical swipes on a phone still scroll the page.
- VOTE is a button. The next question is announced in a polite live region (“Question 2 of 2: …”), and so is the end of the round.
- The batteries, rings, sparkles and touch mark are decorative and hidden from screen readers; the counter is spoken through the announcement instead.
- Sound only follows the reader’s own press or key, stays quiet (the loudest tick is soft), and can be turned off with sound={false}; nothing the card says depends on it.
- Under reduced motion the thumb goes straight to its place, the question swaps at once, the ends send no ripples or sparkles, the bolt and glow switch without fading and the demo never plays.