# Battery slider

> A check-in card whose slider charges a battery from red to green, with a bolt, a glow and soft synthesised ticks.

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.

A React component by Bruno Delorence: https://brunodelorence.com/components/battery-slider. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Inputs.

## Anatomy

1. **Counter**: Which question of how many, as `1/2`. Its changing digit rolls on a vote; with one question it is hidden but keeps its room.
2. **Question**: The current question, which labels the slider for screen readers. On a vote only the words that differ from the next question blur away.
3. **Batteries**: Drained, half and full, above the places the thumb takes at its ends and its middle. The nearest grows and the others shrink as the thumb moves.
4. **Track**: A real `role="slider"` from 0 to 100: a press jumps the thumb there, a drag carries it, and arrows, Page Up/Down, Home and End move it.
5. **Fill**: Runs from the middle of the track to the thumb and wraps it: red to yellow on the left, yellow to green on the right.
6. **Thumb**: Rides a spring towards the pointer or the key’s step, so it lands at an end with a small bounce.
7. **End dot**: One at each end of the track, where the thumb’s centre stops, so the ends show before it gets there.
8. **Vote**: Records the answer (`onVote`), sends the thumb back to the middle and brings the next question; after the last, `onComplete` gets every answer.

## 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.

## Installation

Install it with the shadcn CLI, into components/battery-slider of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/battery-slider.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19.2 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: 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

```tsx
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}
    />
  );
}
```

## States

- **Untouched**: Every question starts in the middle (`defaultValue`, 50): no fill, the half battery largest and the full one idle.
- **Leaning drained**: At 25 the fill runs left from the middle, red to yellow, and the drained battery grows.
- **Charging**: At 75 the full battery gives up its idle charge for one that fills with the thumb, orange to green, under a white bolt.
- **Drained end**: At 0 a red ripple leaves the drained battery once as it mounts; held there by a finger, it beats on.
- **Charged end**: At 100 the bolt gives way to a green glow, and a green ring and a burst of sparkles leave the battery once.
- **Next question**: After a vote: the counter reads `2/2` and only the changed words of the question are new.
- **One question**: With a single question the counter is hidden; voting asks it again.
- **Dark**: With `colorScheme="dark"` (or a dark page) the card turns dark grey; the fill and the charge stay the same.

## How it works

- 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.

## API reference

- **questions** (`{ title: string; labels?: [string, string, string] }[]`, default `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`, default `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`, default `'Vote'`): The button’s words, shown in capitals.
- **sound** (`boolean`, default `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`, default `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.

## Accessibility

- 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.

## Source

### battery-slider/battery-slider.tsx

```tsx
// 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
 * middle; VOTE takes the next question.
 *
 * - The thumb rides a spring towards its target (the pointer, a key, the middle again after a
 *   vote), so it lands with a small bounce at an end. The spring writes one custom property, --p
 *   (0 to 1), once per frame; the thumb's transform, the fill's box and the batteries' sizes are
 *   all calc()s of it in the CSS.
 * - The fill runs from the middle of the track to the thumb and wraps it: red to yellow on the
 *   left, yellow to green on the right. Both gradients share one box and cross over at the middle.
 * - The battery nearest the thumb grows, the others shrink. Past the middle the full battery
 *   gives up its idle charge for one that fills red to green with the thumb under a bolt, and
 *   glows once it is full (icons.tsx). Reaching the left end sends a red ripple out of the drained
 *   battery (again and again while it is held there); reaching the right end, a green ring and a
 *   burst of sparkles.
 * - Sound (click-sound.ts, Web Audio, no files): a soft tick each 5 steps the value crosses,
 *   higher and louder as it charges; a falling thud at the drained end, two rising chimes when it
 *   is full, a pop on VOTE. It starts with the reader's first press or key, never from the demo.
 * - VOTE blurs away the words of the question that change and the counter's digit, and the
 *   batteries shrink away one after the other; the next question's words and batteries come in
 *   the same way.
 * - While on screen and untouched, the demo plays the questions with a touch mark for a finger:
 *   it holds the slider at the drained end a while, charges it all the way, then votes. It makes
 *   no sound.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets.
 */

export type BatteryQuestion = {
  /** The question. Words it shares with the one before stay put when it comes in. */
  title: string;
  /** What the left, middle and right of the slider mean, for screen readers. */
  labels?: [string, string, string];
};

const QUESTIONS: BatteryQuestion[] = [
  { title: 'How are you feeling physically today?' },
  { title: 'How much energy do you have right now?' },
];
const LABELS: [string, string, string] = ['Drained', 'Okay', 'Charged'];
/** The value ticks each time it crosses a multiple of this. */
const TICK = 5;

/* The card's geometry, in its own units (the card is 382 wide). */
const CARD = 382;
/** The track: 276 wide; the thumb (47 wide) sits 3.75 inside it and travels 221.5. */
const TRACK = 276;
const INSET = 3.75;
const THUMB = 47;
const TRAVEL = TRACK - 2 * INSET - THUMB;
/** The track's left and the middle of its height; the middle of VOTE. */
const TRACK_X = 53;
const TRACK_Y = 223;
const VOTE_Y = 291;

/** The spring that follows a finger or a key: lands with about 15% of bounce. */
const FOLLOW = { stiffness: 560, damping: 26 };
/** The one that brings the thumb back to the middle after a vote, critically damped (~400 ms). */
const SETTLE = { stiffness: 190, damping: 27 };

const clamp = (value: number, min = 0, max = 1) => Math.min(max, Math.max(min, value));
const easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);

/** How many leading words two questions share. */
function sharedWords(a: string, b: string) {
  const left = a.split(' ');
  const right = b.split(' ');
  let count = 0;
  while (count < left.length && count < right.length && left[count] === right[count]) count++;
  // A question that only differs by its end keeps at least one word changing.
  return Math.min(count, right.length - 1);
}

/** The words a value reads as: the nearest of the three labels. */
const describe = (value: number, labels: [string, string, string]) =>
  labels[value < 34 ? 0 : value > 66 ? 2 : 1];

export function BatterySlider({
  questions = QUESTIONS,
  value,
  defaultValue = 50,
  defaultQuestion,
  onChange,
  onVote,
  onComplete,
  voteLabel = 'Vote',
  sound = true,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The questions, asked in turn; after the last one's vote it starts again at the first. */
  questions?: BatteryQuestion[];
  /** The current question's answer, 0 to 100, for a controlled slider. */
  value?: number;
  /** Where the slider starts for each question (the middle). */
  defaultValue?: number;
  /** The question (its index) the card opens on; setting it turns the demo off. */
  defaultQuestion?: number;
  /** Called with the new answer and the question's index as the reader moves the slider. */
  onChange?: (value: number, index: number) => void;
  /** Called when the reader votes, with the answer and the question's index. */
  onVote?: (value: number, index: number) => void;
  /** Called after the last question's vote with every answer. */
  onComplete?: (values: number[]) => void;
  /** The button's words (shown in capitals). */
  voteLabel?: string;
  /** Soft synthesised ticks as the value moves, and sounds at the ends and on VOTE. */
  sound?: boolean;
  /** While on screen and untouched, the card plays the questions by itself, without sound. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(5000);
  const id = useId();
  const [index, setIndex] = useState(() =>
    defaultQuestion === undefined ? 0 : Math.max(0, Math.floor(defaultQuestion)) % questions.length,
  );
  const [own, setOwn] = useState(defaultValue);
  const [message, setMessage] = useState('');
  const current = value ?? own;
  const question = questions[index % questions.length];
  const labels = question.labels ?? LABELS;
  const total = questions.length;

  const card = useRef<HTMLDivElement>(null);
  const track = useRef<HTMLDivElement>(null);
  const title = useRef<HTMLParagraphElement>(null);
  const counter = useRef<HTMLParagraphElement>(null);
  const touch = useRef<HTMLDivElement>(null);
  const slots = useRef<(HTMLDivElement | null)[]>([]);

  /** The spring and what the frame loop needs from the latest render. */
  const m = useRef({
    x: current / 100,
    v: 0,
    target: current / 100,
    spring: FOLLOW,
    frame: 0,
    last: 0,
    /** A finger or the demo's is pressing the slider. */
    held: false,
    /** The touch mark follows the thumb. */
    follow: false,
    /** At an end, until the thumb leaves it again. */
    low: false,
    high: false,
    reduced: false,
    /** The demo is playing: it makes no sound. */
    demo: false,
    /** The reader has moved the card since the demo last played: their moves may sound. */
    reader: false,
  });
  const latest = useRef({ value: current, index });
  latest.current = { value: current, index };
  /** A vote's transition is running; and what it needs once the next question renders. */
  const busy = useRef(false);
  const swap = useRef({ shared: 0, counter: '', entering: false });
  const timers = useRef<number[]>([]);
  const grab = useRef<{ id: number; offset: number } | null>(null);
  /** Each question's answer, for onComplete. */
  const answers = useRef<number[]>([]);
  /** The sounds, and whether they may play. */
  const clicks = useRef<ClickSound | null>(null);
  const audible = useRef(sound);
  audible.current = sound;
  /** The reader is making a sound play: their own gesture, never the demo. */
  const voice = () =>
    audible.current && m.current.reader && !m.current.demo ? clicks.current : null;
  /** The reader's own press or key: it may start the sound, and their moves sound from now on. */
  const heard = () => {
    m.current.reader = true;
    if (audible.current) clicks.current?.unlock();
  };

  const later = (fn: () => void, ms: number) => {
    timers.current.push(window.setTimeout(fn, ms));
  };
  /** The card's unit in px. */
  const unit = () => (card.current ? card.current.offsetWidth / CARD : 1);

  /* The ends: a red ripple from the drained battery, a green ring and sparkles from the full one. */
  const ripple = () => {
    const ring = slots.current[0]?.querySelector<HTMLElement>('[data-ring="low"]');
    if (!ring || m.current.reduced) return;
    const u = unit();
    ring.animate(
      [
        { scale: '1', opacity: 0.95, borderWidth: `${1.5 * u}px` },
        { scale: '1.42', opacity: 0, borderWidth: `${0.8 * u}px` },
      ],
      { duration: 640, easing: 'cubic-bezier(0.2, 0.6, 0.35, 1)' },
    );
    // Held at the end, it beats on.
    later(() => {
      if (m.current.low && m.current.held) ripple();
    }, 470);
  };

  const sparkle = (
    spark: HTMLElement,
    angle: number,
    from: number,
    to: number,
    delay: number,
    duration: number,
  ) => {
    const u = unit();
    const at = (r: number) =>
      `${(Math.cos(angle) * r * u).toFixed(1)}px ${(Math.sin(angle) * r * u).toFixed(1)}px`;
    spark.animate(
      [
        { translate: at(from), scale: '0', opacity: 0, rotate: '-20deg' },
        { translate: at(to * 0.92), scale: '1.15', opacity: 1, rotate: '0deg', offset: 0.22 },
        { translate: at(to), scale: '0.7', opacity: 1, offset: 0.5 },
        { translate: at(to), scale: '1', opacity: 1, offset: 0.72 },
        { translate: at(to), scale: '0', opacity: 0, rotate: '15deg' },
      ],
      { duration, delay, easing: 'ease-out', fill: 'backwards' },
    );
  };

  const burst = () => {
    const slot = slots.current[2];
    if (!slot || m.current.reduced) return;
    const u = unit();
    slot.querySelector<HTMLElement>('[data-ring="high"]')?.animate(
      [
        { scale: '0.94', opacity: 0, borderWidth: `${1 * u}px` },
        { scale: '1.06', opacity: 1, borderWidth: `${2.6 * u}px`, offset: 0.1 },
        { scale: '1.62', opacity: 0, borderWidth: `${0.9 * u}px` },
      ],
      { duration: 900, easing: 'cubic-bezier(0.15, 0.6, 0.3, 1)' },
    );
    const sparks = slot.querySelectorAll<HTMLElement>('[data-spark]');
    const angles = [-140, -22, 128];
    angles.forEach((angle, i) => {
      const spark = sparks[i];
      if (spark) sparkle(spark, (angle * Math.PI) / 180, 24, 40, 60 + i * 70, 1250);
    });
    // One more twinkles a moment later, while the thumb stays at the end.
    later(() => {
      const spark = sparks[3];
      if (spark && m.current.high) sparkle(spark, (35 * Math.PI) / 180, 31, 36, 0, 1100);
    }, 1300);
  };

  /** Draws one frame for the thumb at `x`. */
  const paint = (x: number) => {
    const state = m.current;
    card.current?.style.setProperty('--p', x.toFixed(4));
    slots.current.forEach((slot, i) => {
      const svg = slot?.querySelector<SVGSVGElement>('[data-icon] svg');
      if (svg) morphIcon(svg, i as Slot, x);
    });
    if (x <= 0.006 && state.target === 0 && !state.low) {
      state.low = true;
      voice()?.low();
      ripple();
    }
    if (x > 0.06) state.low = false;
    if (x >= 0.994 && state.target === 1 && !state.high) {
      state.high = true;
      voice()?.high();
      burst();
    }
    if (x < 0.94) state.high = false;
    if (state.follow && touch.current) {
      const u = unit();
      touch.current.style.translate = `${((TRACK_X + INSET + THUMB / 2 + x * TRAVEL) * u).toFixed(1)}px ${(TRACK_Y * u).toFixed(1)}px`;
    }
  };

  const step = (now: number) => {
    const state = m.current;
    const dt = Math.min(0.034, (now - state.last) / 1000 || 0.016);
    state.last = now;
    // Small fixed steps keep the spring steady whatever the frame rate.
    for (let t = 0; t < dt; t += 0.004) {
      const h = Math.min(0.004, dt - t);
      const a = -state.spring.stiffness * (state.x - state.target) - state.spring.damping * state.v;
      state.v += a * h;
      state.x += state.v * h;
    }
    const done = Math.abs(state.x - state.target) < 0.0004 && Math.abs(state.v) < 0.002;
    if (done) {
      state.x = state.target;
      state.v = 0;
    }
    paint(state.x);
    state.frame = done ? 0 : requestAnimationFrame(step);
  };

  /** Sends the thumb to `to` (0 to 1) on a spring; straight there under reduced motion. */
  const aim = (to: number, spring = FOLLOW) => {
    const state = m.current;
    state.target = clamp(to);
    state.spring = spring;
    if (state.reduced) {
      cancelAnimationFrame(state.frame);
      state.frame = 0;
      state.x = state.target;
      state.v = 0;
      paint(state.x);
      return;
    }
    if (!state.frame) {
      state.last = performance.now();
      state.frame = requestAnimationFrame(step);
    }
  };

  // The first frame, before paint, reduced motion and the sound.
  useLayoutEffect(() => {
    const state = m.current;
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const read = () => {
      state.reduced = motion.matches;
    };
    read();
    paint(state.x);
    motion.addEventListener('change', read);
    clicks.current = clickSound();
    const pending = timers.current;
    return () => {
      motion.removeEventListener('change', read);
      cancelAnimationFrame(state.frame);
      state.frame = 0;
      pending.forEach((timer) => window.clearTimeout(timer));
      clicks.current?.dispose();
      clicks.current = null;
    };
  }, []);

  // A new value from outside (or a key) moves the thumb; a drag already did.
  useLayoutEffect(() => {
    if (Math.abs(m.current.target * 100 - current) >= 0.5) aim(current / 100, m.current.spring);
  }, [current]);

  /** Repeats are made to differ, so screen readers announce them again. */
  const announce = (text: string) => setMessage((now) => (now === text ? `${text} ` : text));

  /** Takes a new answer; `quiet` for the demo's and the vote's, which neither report nor tick. */
  const commit = (next: number, quiet = false) => {
    const rounded = Math.round(clamp(next, 0, 100));
    const before = latest.current.value;
    if (rounded === before) return;
    latest.current.value = rounded;
    if (value === undefined) setOwn(rounded);
    if (quiet) return;
    onChange?.(rounded, latest.current.index);
    // A tick each time the value crosses a step, at its pitch.
    if (Math.floor(rounded / TICK) !== Math.floor(before / TICK)) voice()?.play(rounded / 100);
  };

  /* The slider by pointer: a press jumps the thumb there (or grabs it), a drag carries it. */
  const fractionAt = (clientX: number) => {
    const element = track.current;
    if (!element) return 0.5;
    const rect = element.getBoundingClientRect();
    const scale = rect.width / TRACK;
    return (clientX - rect.left - (INSET + THUMB / 2) * scale) / (TRAVEL * scale);
  };
  const moveTo = (fraction: number) => {
    const to = clamp(fraction);
    aim(to, FOLLOW);
    commit(to * 100);
  };
  const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
    if (event.button !== 0 || grab.current) return;
    event.preventDefault();
    event.currentTarget.setPointerCapture(event.pointerId);
    event.currentTarget.focus({ preventScroll: true });
    heard();
    const at = fractionAt(event.clientX);
    const onThumb = Math.abs(at - m.current.x) < THUMB / 2 / TRAVEL;
    grab.current = { id: event.pointerId, offset: onThumb ? at - m.current.x : 0 };
    m.current.held = true;
    moveTo(at - grab.current.offset);
  };
  const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
    if (grab.current?.id !== event.pointerId) return;
    moveTo(fractionAt(event.clientX) - grab.current.offset);
  };
  const onPointerEnd = (event: ReactPointerEvent<HTMLDivElement>) => {
    if (grab.current?.id !== event.pointerId) return;
    grab.current = null;
    m.current.held = false;
    // A touch counts as a gesture that may start sound only once it lifts.
    if (event.type === 'pointerup') heard();
  };

  const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
    const now = latest.current.value;
    const keys: Record<string, number> = {
      ArrowLeft: now - 5,
      ArrowDown: now - 5,
      ArrowRight: now + 5,
      ArrowUp: now + 5,
      PageDown: now - 20,
      PageUp: now + 20,
      Home: 0,
      End: 100,
    };
    const next = keys[event.key];
    if (next === undefined) return;
    event.preventDefault();
    heard();
    const to = clamp(next, 0, 100);
    aim(to / 100, FOLLOW);
    commit(to);
  };

  /* VOTE: the changing words, the counter's digit and the icons leave; the next question comes. */
  const vote = async (fromDemo = false) => {
    if (busy.current) return;
    busy.current = true;
    const { value: answer, index: at } = latest.current;
    const next = (at + 1) % total;
    if (!fromDemo) {
      heard();
      voice()?.vote();
      onVote?.(answer, at);
      answers.current[at] = answer;
      if (next === 0) onComplete?.(questions.map((_, i) => answers.current[i] ?? defaultValue));
    }
    const words = [...(title.current?.querySelectorAll<HTMLElement>('[data-word]') ?? [])];
    const shared = sharedWords(questions[at].title, questions[next].title);
    const oldCounter = `${at + 1}/${total}`;
    swap.current = { shared, counter: oldCounter, entering: true };
    // The end's ripples, ring and sparkles stop with the question.
    m.current.high = false;
    m.current.low = false;
    slots.current.forEach((slot) =>
      slot
        ?.querySelectorAll<HTMLElement>('[data-ring], [data-spark]')
        .forEach((element) => element.getAnimations().forEach((animation) => animation.cancel())),
    );
    // The thumb glides back to the middle (quietly: the vote made its own sound).
    aim(defaultValue / 100, SETTLE);
    commit(defaultValue, true);
    if (!fromDemo) onChange?.(defaultValue, next);

    if (!m.current.reduced) {
      const newCounter = `${next + 1}/${total}`;
      const leaving: Animation[] = [];
      words.slice(shared).forEach((word, i) => {
        leaving.push(
          word.animate(
            [{ opacity: 0, filter: 'blur(0.22em)', transform: 'translateY(-0.42em) scaleY(0.3)' }],
            {
              duration: 180,
              delay: i * 12,
              easing: 'cubic-bezier(0.5, 0, 0.75, 0)',
              fill: 'forwards',
            },
          ),
        );
      });
      counter.current?.querySelectorAll<HTMLElement>('[data-char]').forEach((char, i) => {
        if (oldCounter[i] === newCounter[i]) return;
        leaving.push(
          char.animate([{ opacity: 0, filter: 'blur(0.15em)', transform: 'translateY(-0.7em)' }], {
            duration: 170,
            easing: 'ease-in',
            fill: 'forwards',
          }),
        );
      });
      const u = unit();
      slots.current.forEach((slot, i) => {
        const pop = slot?.querySelector<HTMLElement>('[data-pop]');
        if (!pop) return;
        leaving.push(
          pop.animate([{ scale: '0', opacity: 0, filter: `blur(${2 * u}px)` }], {
            duration: 170,
            delay: i * 35,
            easing: 'cubic-bezier(0.5, 0, 0.75, 0)',
            fill: 'forwards',
          }),
        );
      });
      await Promise.all(leaving.map((animation) => animation.finished.catch(() => undefined)));
    }
    setIndex(next);
    const heading = `Question ${next + 1} of ${total}: ${questions[next].title}`;
    announce(next === 0 ? `Thanks, your answers are in. ${heading}` : heading);
    if (m.current.reduced) busy.current = false;
  };
  // The next question's changing words, digit and icons come in.
  useLayoutEffect(() => {
    if (!swap.current.entering) return;
    swap.current.entering = false;
    const { shared, counter: oldCounter } = swap.current;
    const done = () => {
      busy.current = false;
    };
    if (m.current.reduced) return done();
    const u = unit();
    const words = [...(title.current?.querySelectorAll<HTMLElement>('[data-word]') ?? [])];
    // Words, digit and icons left with fill: forwards; their old animations go with the old nodes,
    // or are dropped here.
    const all = [
      ...words,
      ...(counter.current?.querySelectorAll<HTMLElement>('[data-char]') ?? []),
      ...slots.current.map((slot) => slot?.querySelector<HTMLElement>('[data-pop]')),
    ];
    all.forEach((element) => element?.getAnimations().forEach((animation) => animation.cancel()));
    const entering: Animation[] = [];
    words.slice(shared).forEach((word, i) => {
      entering.push(
        word.animate(
          [
            {
              opacity: 0,
              filter: 'blur(0.22em)',
              transform: 'translateY(0.3em) scaleY(0.6)',
              clipPath: 'inset(0 0 100% 0)',
            },
            { opacity: 1, filter: 'blur(0)', transform: 'none', clipPath: 'inset(0 0 -20% 0)' },
          ],
          {
            duration: 320,
            delay: i * 18,
            easing: 'cubic-bezier(0.2, 0.7, 0.2, 1)',
            fill: 'backwards',
          },
        ),
      );
    });
    const newCounter = `${latest.current.index + 1}/${total}`;
    counter.current?.querySelectorAll<HTMLElement>('[data-char]').forEach((char, i) => {
      if (oldCounter[i] === newCounter[i]) return;
      entering.push(
        char.animate(
          [
            { opacity: 0, filter: 'blur(0.15em)', transform: 'translateY(0.7em)' },
            { opacity: 1, filter: 'blur(0)', transform: 'none' },
          ],
          { duration: 260, easing: 'cubic-bezier(0.2, 0.7, 0.2, 1)', fill: 'backwards' },
        ),
      );
    });
    slots.current.forEach((slot, i) => {
      const pop = slot?.querySelector<HTMLElement>('[data-pop]');
      if (!pop) return;
      entering.push(
        pop.animate(
          [
            { scale: '0', opacity: 0, filter: `blur(${2 * u}px)` },
            { scale: '1.08', opacity: 1, filter: 'blur(0px)', offset: 0.6 },
            { scale: '1', opacity: 1, filter: 'blur(0px)' },
          ],
          {
            duration: 380,
            delay: i * 45,
            easing: 'cubic-bezier(0.3, 0.7, 0.4, 1)',
            fill: 'backwards',
          },
        ),
      );
    });
    // The icons are new drawings: draw them for where the thumb is.
    paint(m.current.x);
    Promise.all(entering.map((animation) => animation.finished.catch(() => undefined))).then(done);
  }, [index]);

  // The title's light-to-grey sweep is one gradient across the whole question, shared by its
  // words: each word shows its own part of it.
  useLayoutEffect(() => {
    const element = title.current;
    if (!element) return;
    const measure = () => {
      element.style.setProperty('--gw', `${element.offsetWidth}px`);
      element.style.setProperty('--gh', `${element.offsetHeight}px`);
      element.querySelectorAll<HTMLElement>('[data-word]').forEach((word) => {
        word.style.setProperty('--gx', `${element.offsetLeft - word.offsetLeft}px`);
        word.style.setProperty('--gy', `${element.offsetTop - word.offsetTop}px`);
      });
    };
    measure();
    // Words move when the face loads, though the title may keep its size.
    let alive = true;
    document.fonts?.ready.then(() => alive && measure());
    const observer = new ResizeObserver(measure);
    observer.observe(element);
    return () => {
      alive = false;
      observer.disconnect();
    };
  }, [question.title]);

  /* The touch mark: the demo's finger. */
  const touchAt = (x: number, y: number, press: boolean) => {
    const mark = touch.current;
    if (!mark) return;
    const u = unit();
    mark.style.translate = `${(x * u).toFixed(1)}px ${(y * u).toFixed(1)}px`;
    if (press) {
      mark.dataset.down = 'true';
      mark.querySelector('[data-halo]')?.animate(
        [
          { scale: '1.4', opacity: 0.9 },
          { scale: '1', opacity: 0 },
        ],
        { duration: 420, easing: 'ease-out' },
      );
    } else delete mark.dataset.down;
  };

  // The demo: like a person checking in. Drained first, then charged all the way, then VOTE.
  const demoVote = useEffectEvent(() => vote(true));
  const demoValue = useEffectEvent((to: number) => commit(to, true));
  const demo = autoplay && defaultQuestion === undefined && playing;
  useEffect(() => {
    if (!demo) return;
    const state = m.current;
    state.demo = true;
    state.reader = false;
    let alive = true;
    let frame = 0;
    const ids: number[] = [];
    const stop = new Error('stopped');
    const wait = (ms: number) =>
      new Promise<void>((resolve, reject) => {
        ids.push(window.setTimeout(() => (alive ? resolve() : reject(stop)), ms));
      });
    /** Drags the thumb from where it is towards `to` (past an end, so it lands with a bounce). */
    const drag = (to: number, ms: number) =>
      new Promise<void>((resolve, reject) => {
        const from = state.target;
        const start = performance.now();
        const tick = (now: number) => {
          if (!alive) return reject(stop);
          const t = Math.min(1, (now - start) / ms);
          aim(from + (to - from) * easeInOut(t), FOLLOW);
          if (t < 1) frame = requestAnimationFrame(tick);
          else resolve();
        };
        frame = requestAnimationFrame(tick);
      });
    const press = async () => {
      state.follow = true;
      state.held = true;
      touchAt(TRACK_X + INSET + THUMB / 2 + state.x * TRAVEL, TRACK_Y, true);
      await wait(170);
    };
    const lift = () => {
      state.follow = false;
      state.held = false;
      touchAt(TRACK_X + INSET + THUMB / 2 + state.x * TRAVEL, TRACK_Y, false);
    };
    (async () => {
      await wait(900);
      for (;;) {
        await press();
        await drag(-0.12, 820);
        demoValue(0);
        // The finger stays at the drained end a while: the ripple beats on.
        await wait(1500);
        lift();
        await wait(700);
        await press();
        await drag(1.1, 1000);
        demoValue(100);
        await wait(300);
        lift();
        await wait(2100);
        touchAt(CARD / 2, VOTE_Y, true);
        await wait(240);
        touchAt(CARD / 2, VOTE_Y, false);
        demoVote();
        await wait(1500);
      }
    })().catch(() => undefined);
    return () => {
      alive = false;
      cancelAnimationFrame(frame);
      ids.forEach((timer) => window.clearTimeout(timer));
      state.follow = false;
      state.held = false;
      state.demo = false;
      if (touch.current) delete touch.current.dataset.down;
      // The reader takes over where the demo left the thumb.
      const at = Math.round(clamp(state.target) * 100);
      aim(at / 100, FOLLOW);
      demoValue(at);
    };
  }, [demo]);

  const counterText = `${index + 1}/${total}`;
  const words = question.title.split(' ');

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
    >
      <div className={styles.stage}>
        <div ref={card} className={styles.card}>
          <p
            ref={counter}
            className={styles.counter}
            data-part="counter"
            aria-hidden="true"
            // One question needs no counter; its room stays, so the card keeps its shape.
            style={total < 2 ? { visibility: 'hidden' } : undefined}
          >
            {[...counterText].map((char, i) => (
              <span key={`${index}-${i}`} data-char className={styles.char}>
                {char}
              </span>
            ))}
          </p>
          <p ref={title} id={`${id}-title`} className={styles.title} data-part="question">
            {words.map((word, i) => (
              <span key={`${index}-${i}`}>
                {i > 0 && ' '}
                <span data-word className={styles.word}>
                  {word}
                </span>
              </span>
            ))}
          </p>
          <div className={styles.icons} data-part="batteries" aria-hidden="true">
            {([0, 1, 2] as Slot[]).map((slot) => (
              <div
                key={slot}
                ref={(element) => {
                  slots.current[slot] = element;
                }}
                className={styles.slot}
                data-part="battery"
                style={{ '--at': slot / 2, '--small': SMALL } as CSSProperties}
              >
                {slot === 0 && <span data-ring="low" className={styles.ringLow} />}
                {slot === 2 && (
                  <>
                    <span data-ring="high" className={styles.ringHigh} />
                    {['green', 'gold', 'lime', 'green'].map((tone, i) => (
                      <span key={i} data-spark className={styles.spark} data-tone={tone}>
                        <svg viewBox="0 0 20 20" focusable="false">
                          <path d="M10 0c.8 6 4 9.2 10 10-6 .8-9.2 4-10 10-.8-6-4-9.2-10-10 6-.8 9.2-4 10-10z" />
                        </svg>
                      </span>
                    ))}
                  </>
                )}
                <div data-pop className={styles.pop} key={index}>
                  <div data-icon className={styles.grow}>
                    <Icon slot={slot} id={`${id}-${index}-${slot}`} className={styles.art} />
                  </div>
                </div>
              </div>
            ))}
          </div>
          <div
            ref={track}
            className={styles.track}
            data-part="track"
            role="slider"
            tabIndex={0}
            aria-labelledby={`${id}-title`}
            aria-valuemin={0}
            aria-valuemax={100}
            aria-valuenow={current}
            aria-valuetext={`${describe(current, labels)}, ${current} of 100`}
            aria-orientation="horizontal"
            onPointerDown={onPointerDown}
            onPointerMove={onPointerMove}
            onPointerUp={onPointerEnd}
            onPointerCancel={onPointerEnd}
            onLostPointerCapture={onPointerEnd}
            onKeyDown={onKeyDown}
          >
            <span className={styles.dot} data-part="end" data-end="low" />
            <span className={styles.dot} data-part="end" data-end="high" />
            <span className={styles.fill} data-part="fill">
              <span className={styles.fillLow} />
              <span className={styles.fillHigh} />
            </span>
            <span className={styles.thumb} data-part="thumb">
              <span className={styles.grip} data-part="grip" />
            </span>
          </div>
          <div className={styles.footer}>
            <button type="button" className={styles.vote} data-part="vote" onClick={() => vote()}>
              {voteLabel}
            </button>
          </div>
          <div ref={touch} className={styles.touch} aria-hidden="true">
            <span data-halo className={styles.halo} />
          </div>
        </div>
      </div>
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}
```

### battery-slider/battery-slider.module.css

```css
/* 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;
  --card: #fff;
  --edge: rgb(0 0 0 / 0.05);
  --lift: rgb(0 0 0 / 0.06);
  /* The question, light at its start and greyer towards its end; the counter. */
  --ink: #141414;
  --ink-end: #707070;
  --muted: #9a9a9a;
  /* The track, its end dots, the line over VOTE and the words of VOTE. */
  --track: #efefef;
  --track-edge: rgb(0 0 0 / 0.03);
  --dot: #c8c8c8;
  --rule: #ececec;
  --vote: #141414;
  /* The thumb: white, with a soft shade under it, and its grip. */
  --thumb: #fff;
  --thumb-shade: #ececec;
  --thumb-shadow: rgb(0 0 0 / 0.2);
  --grip: #d4d4d4;
  /* The batteries' case. */
  --battery: #cfd0d0;
  /* The same on both schemes: the focus ring, the red ripple, the green ring and the sparkles,
     and the touch mark of the demo's finger. */
  --focus: #387bff;
  --ripple: #e2441c;
  --ring: #2bb866;
  --spark-green: #35d282;
  --spark-gold: #e6b94c;
  --spark-lime: #a9d861;
  position: relative;
  container-type: inline-size;
  contain: layout;
  width: 100%;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--stage);
  box-shadow: inset 0 0 0 1px var(--edge);
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --stage: light-dark(#efefef, #1a1a1a);
    --card: light-dark(#fff, #262626);
    --edge: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.04));
    --lift: light-dark(rgb(0 0 0 / 0.06), rgb(0 0 0 / 0.25));
    --ink: light-dark(#141414, #fff);
    --ink-end: light-dark(#707070, #c2c2c2);
    --muted: light-dark(#9a9a9a, #777);
    --track: light-dark(#efefef, #303030);
    --track-edge: light-dark(rgb(0 0 0 / 0.03), rgb(0 0 0 / 0.12));
    --dot: light-dark(#c8c8c8, #545454);
    --rule: light-dark(#ececec, #2f2f2f);
    --vote: light-dark(#141414, #f2f2f2);
    --thumb: light-dark(#fff, #fafafa);
    --thumb-shade: light-dark(#ececec, #e4e4e4);
    --thumb-shadow: light-dark(rgb(0 0 0 / 0.2), rgb(0 0 0 / 0.42));
    --grip: light-dark(#d4d4d4, #d6d6d6);
    --battery: light-dark(#cfd0d0, #575958);
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

/* The stage: the card in its middle, with room around it. The card is 84% of the stage's width,
   at most 573 px (1.5 px a unit). */
.stage {
  --u: min(calc(100cqi * 0.84 / 382), 1.5px);
  display: grid;
  place-items: center;
  padding: calc(34 * var(--u)) 0;
}

.card {
  position: relative;
  box-sizing: border-box;
  width: calc(382 * var(--u));
  min-height: calc(327 * var(--u));
  padding: 0 0 calc(1 * var(--u));
  border-radius: calc(20 * var(--u));
  background: var(--card);
  box-shadow:
    0 0 0 max(1px, calc(0.6 * var(--u))) var(--edge),
    0 calc(8 * var(--u)) calc(28 * var(--u)) var(--lift);
  /* The site's geometric face, else the page's. */
  font-family: Satoshi, Inter, system-ui, sans-serif;
  text-align: center;
  -webkit-font-smoothing: antialiased;
  user-select: none;
  -webkit-user-select: none;
}

/* "1/2": its digit rolls on a vote. */
.counter {
  height: calc(14 * var(--u));
  margin: calc(33.6 * var(--u)) 0 0;
  color: var(--muted);
  font-size: calc(10.5 * var(--u));
  font-weight: 500;
  line-height: calc(14 * var(--u));
  letter-spacing: 0.12em;
}
.char {
  display: inline-block;
}

/* The question: two lines, the changing words blur across on a vote. */
.title {
  --gw: 100%;
  --gh: 100%;
  min-height: calc(61 * var(--u));
  margin: calc(5.2 * var(--u)) auto 0;
  max-width: calc(300 * var(--u));
  color: var(--ink);
  font-size: calc(25.5 * var(--u));
  font-weight: 700;
  line-height: calc(30.5 * var(--u));
  letter-spacing: 0.018em;
  text-wrap: balance;
}
.word {
  display: inline-block;
  transform-origin: 50% 0;
  background: linear-gradient(160deg, var(--ink) 52%, var(--ink-end) 100%) var(--gx, 0)
    var(--gy, 0) / var(--gw) var(--gh) no-repeat;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}

/* The batteries: three slots at the thumb's places for 0, the middle and 1. */
.icons {
  position: relative;
  height: calc(64 * var(--u));
  margin: calc(18.9 * var(--u)) calc(53 * var(--u)) 0;
}
.slot {
  --q: clamp(0, var(--p, 0.5), 1);
  position: absolute;
  top: 50%;
  left: calc((27.25 + var(--at) * 221.5) * var(--u));
  width: 0;
  height: 0;
}
.pop {
  position: absolute;
  top: 0;
  left: 0;
}
/* The nearer the thumb, the larger: from --small on to full size where the thumb is on it. */
.grow {
  translate: -50% -50%;
  scale: calc(
    var(--small) + (1 - var(--small)) *
      max(0, 1 - 2 * max(var(--q) - var(--at), var(--at) - var(--q)))
  );
}
.art {
  display: block;
  width: calc(46 * var(--u));
  height: calc(29 * var(--u));
  overflow: visible;
  filter: drop-shadow(0 calc(1.5 * var(--u)) calc(2 * var(--u)) rgb(0 0 0 / 0.22));
  transition: filter 300ms ease;
}
.grow [data-part='bolt'] {
  transform-origin: 21px 14.5px;
  transition:
    opacity 180ms ease,
    scale 180ms ease;
}
.grow:not([data-bolt='true']) [data-part='bolt'] {
  opacity: 0;
  scale: 0.6;
}
/* Charged: the full battery glows. */
.grow[data-full='true'] .art {
  filter: drop-shadow(0 0 calc(5 * var(--u)) rgb(46 214 122 / 0.6));
}

/* The rings at the ends: red ripples on the drained battery, a green ring on the full one. */
.ringLow,
.ringHigh {
  position: absolute;
  top: 0;
  left: 0;
  box-sizing: border-box;
  width: calc(50 * var(--u));
  height: calc(50 * var(--u));
  translate: -50% -50%;
  border: calc(1.5 * var(--u)) solid var(--ripple);
  border-radius: 50%;
  opacity: 0;
  pointer-events: none;
}
.ringHigh {
  border-color: var(--ring);
}
/* Four-pointed sparkles, flung out with the green ring. */
.spark {
  position: absolute;
  top: 0;
  left: 0;
  width: calc(9 * var(--u));
  height: calc(9 * var(--u));
  margin: calc(-4.5 * var(--u)) 0 0 calc(-4.5 * var(--u));
  opacity: 0;
  pointer-events: none;
  color: var(--spark-green);
}
.spark[data-tone='gold'] {
  color: var(--spark-gold);
  scale: 0.9;
}
.spark[data-tone='lime'] {
  color: var(--spark-lime);
}
.spark svg {
  display: block;
  width: 100%;
  height: 100%;
  fill: currentColor;
}

/* The track: the dots mark where the thumb's middle stops at either end. */
.track {
  position: relative;
  height: calc(41.5 * var(--u));
  margin: calc(4.25 * var(--u)) calc(53 * var(--u)) 0;
  border-radius: 999px;
  background: var(--track);
  box-shadow: inset 0 calc(0.6 * var(--u)) calc(1.2 * var(--u)) var(--track-edge);
  touch-action: pan-y;
  cursor: pointer;
  outline: none;
}
.track:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: calc(3 * var(--u));
}
.dot {
  position: absolute;
  top: 50%;
  left: calc(27.25 * var(--u));
  width: calc(3.8 * var(--u));
  height: calc(3.8 * var(--u));
  margin: calc(-1.9 * var(--u));
  border-radius: 50%;
  background: var(--dot);
}
.dot[data-end='high'] {
  left: auto;
  right: calc(27.25 * var(--u));
}
/* The fill: from the middle of the track to the thumb, wrapping it 3.75 units around. */
.fill {
  position: absolute;
  top: 0;
  bottom: 0;
  left: calc(min(var(--p, 0.5) * 221.5, 138) * var(--u));
  width: calc(
    (max(54.5 + var(--p, 0.5) * 221.5, 138) - min(var(--p, 0.5) * 221.5, 138)) * var(--u)
  );
  border-radius: 999px;
  overflow: hidden;
}
.fillLow,
.fillHigh {
  position: absolute;
  inset: 0;
}
.fillLow {
  background: linear-gradient(
    90deg,
    #c9300c 0%,
    #e2501b 16%,
    #f3742e 40%,
    #fe8c3c 52%,
    #fda84c 68%,
    #fdc55f 83%,
    #ffd96a 100%
  );
  opacity: clamp(0, (0.5 - var(--p, 0.5)) * 40, 1);
}
.fillHigh {
  background: linear-gradient(
    90deg,
    #ffd96a 0%,
    #e5dd6e 21%,
    #b0e676 41%,
    #84e878 56%,
    #57d873 76%,
    #2abd66 100%
  );
  opacity: clamp(0, (var(--p, 0.5) - 0.5) * 40, 1);
}
.thumb {
  position: absolute;
  top: calc(3.75 * var(--u));
  left: calc(3.75 * var(--u));
  display: grid;
  place-items: center;
  width: calc(47 * var(--u));
  height: calc(34 * var(--u));
  border-radius: 28% / 50%;
  background: radial-gradient(
    ellipse 85% 75% at 45% 35%,
    var(--thumb) 0%,
    var(--thumb) 55%,
    var(--thumb-shade) 100%
  );
  box-shadow:
    calc(0.6 * var(--u)) calc(2 * var(--u)) calc(5 * var(--u)) var(--thumb-shadow),
    inset 0 calc(-1.2 * var(--u)) calc(1.6 * var(--u)) rgb(0 0 0 / 0.05);
  transform: translateX(calc(var(--p, 0.5) * 221.5 * var(--u)));
  will-change: transform;
}
.grip {
  width: calc(1.7 * var(--u));
  height: calc(9.5 * var(--u));
  border-radius: calc(1 * var(--u));
  background: var(--grip);
}

/* VOTE, under a hairline. */
.footer {
  display: grid;
  place-items: center;
  height: calc(66 * var(--u));
  margin: calc(16.25 * var(--u)) calc(50 * var(--u)) 0;
  border-top: max(1px, calc(0.6 * var(--u))) solid var(--rule);
}
.vote {
  min-width: calc(110 * var(--u));
  height: calc(40 * var(--u));
  margin: calc(-4 * var(--u)) 0 0;
  padding: 0 calc(16 * var(--u));
  border: 0;
  border-radius: 999px;
  background: none;
  color: var(--vote);
  font: inherit;
  font-size: calc(11 * var(--u));
  font-weight: 500;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  cursor: pointer;
}
.vote:focus {
  outline: none;
}
.vote:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 0;
}

/* The demo's finger: a grey disc with a light rim, and a halo that closes on it when it presses. */
.touch {
  position: absolute;
  top: 0;
  left: 0;
  width: calc(32 * var(--u));
  height: calc(32 * var(--u));
  margin: calc(-16 * var(--u)) 0 0 calc(-16 * var(--u));
  border: calc(1.4 * var(--u)) solid rgb(225 225 225 / 0.8);
  border-radius: 50%;
  background: rgb(120 120 120 / 0.72);
  opacity: 0;
  pointer-events: none;
  transition: opacity 200ms ease;
}
.touch[data-down='true'] {
  opacity: 1;
  transition-duration: 80ms;
}
.halo {
  position: absolute;
  inset: calc(-7 * var(--u));
  border: calc(1 * var(--u)) solid rgb(225 225 225 / 0.7);
  border-radius: 50%;
  opacity: 0;
}

.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
  .art,
  .grow [data-part='bolt'] {
    transition: none;
  }
}
```

### battery-slider/icons.tsx

```tsx
// 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)})`;
}

/* Batteries. A 46 by 29 drawing: the case, its cap, the charge and a bolt. */

const BATTERY = [
  { level: 0.11, top: '#ff6a2c', bottom: '#ec4a17' },
  { level: 0.59, top: '#fdc45d', bottom: '#fdab3d' },
  { level: 1, top: '#38e682', bottom: '#20c96c' },
] as const;
/** The charge's colour as it fills: red-orange, orange, yellow, lime, green. */
const CHARGE = [
  [0, '#f0642a'],
  [0.3, '#f98a33'],
  [0.55, '#fdbf4a'],
  [0.8, '#a6dc5e'],
  [1, '#2fd47b'],
] as const;
const INNER = 38.7;

function chargeColour(t: number) {
  for (let i = 1; i < CHARGE.length; i++) {
    const [at, colour] = CHARGE[i];
    const [from, before] = CHARGE[i - 1];
    if (t <= at) return blend(before, colour, (t - from) / (at - from));
  }
  return CHARGE[CHARGE.length - 1][1];
}

/** The right battery's state: how full, in what colours, charging or charged. */
function batteryParts(slot: Slot, p: number) {
  const base = BATTERY[slot];
  if (slot !== 2)
    return { level: base.level, top: base.top, bottom: base.bottom, bolt: 0, full: 0 };
  const c = clamp((p - 0.5) * 2);
  // Leaving the middle, the full idle battery gives way to one that charges as the thumb goes.
  const k = clamp(c / 0.08);
  const colour = chargeColour(c);
  const level = mix(1, mix(0.28, 1, c), k);
  const top = k < 1 ? blend(base.top, '#a6e08a', k) : colour;
  const charged = c > 0.985;
  return {
    level,
    top: k < 1 ? top : colour,
    bottom: k < 1 ? blend(base.bottom, '#8fd277', k) : colour,
    bolt: c > 0.04 && !charged ? 1 : 0,
    full: charged ? 1 : 0,
  };
}

/** Reshapes the battery in `svg` for `p`, the slider's place. */
export function morphIcon(svg: SVGSVGElement, slot: Slot, p: number) {
  const key = p.toFixed(3);
  if (svg.dataset.at === key) return;
  svg.dataset.at = key;
  const part = (name: string) => svg.querySelector<SVGElement>(`[data-part="${name}"]`);
  const battery = batteryParts(slot, p);
  part('charge')?.setAttribute('width', String(n(Math.max(2.4, INNER * battery.level))));
  part('top')?.setAttribute('stop-color', battery.top);
  part('bottom')?.setAttribute('stop-color', battery.bottom);
  const icon = svg.closest<HTMLElement>('[data-icon]');
  if (icon) {
    icon.dataset.bolt = String(battery.bolt === 1);
    icon.dataset.full = String(battery.full === 1);
  }
}

/** One battery, drawn for the slider at its middle; morphIcon() takes it from there. */
export function Icon({ slot, id, className }: { slot: Slot; id: string; className?: string }) {
  const battery = batteryParts(slot, 0.5);
  return (
    <svg className={className} viewBox="0 0 46 29" focusable="false" aria-hidden="true">
      <defs>
        <linearGradient id={`${id}-c`} x1="0" y1="0" x2="0" y2="1">
          <stop data-part="top" offset="0" stopColor={battery.top} />
          <stop data-part="bottom" offset="1" stopColor={battery.bottom} />
        </linearGradient>
      </defs>
      <rect x="40" y="8" width="5.6" height="13" rx="2.2" fill="var(--battery)" />
      <rect width="42.3" height="29" rx="6.6" fill="var(--battery)" />
      <rect
        data-part="charge"
        x="1.8"
        y="1.8"
        width={n(INNER * battery.level)}
        height="25.4"
        rx="4.8"
        fill={`url(#${id}-c)`}
      />
      <path
        data-part="bolt"
        d="M23.6 5.6 15.4 16.2h5.5l-1.9 7.2 8.3-10.6h-5.6z"
        fill="#fff"
        stroke="#fff"
        strokeWidth="0.6"
        strokeLinejoin="round"
      />
    </svg>
  );
}
```

### battery-slider/click-sound.ts

```tsx
/*
 * 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;
let restTimer = 0;

/** The page's audio, made on a gesture of the reader's (or null where the API is missing). */
function audio() {
  if (!context && typeof AudioContext !== 'undefined') {
    context = new AudioContext();
    // 20 ms of white noise, shared by every sound.
    noise = context.createBuffer(1, Math.round(context.sampleRate * 0.02), context.sampleRate);
    const samples = noise.getChannelData(0);
    for (let i = 0; i < samples.length; i += 1) samples[i] = Math.random() * 2 - 1;
  }
  return context;
}

/** Suspends the audio once the sounds have stopped for a while. */
function restSoon() {
  window.clearTimeout(restTimer);
  restTimer = window.setTimeout(() => {
    if (context?.state === 'running') void context.suspend();
  }, REST);
}

/**
 * One blip: a sine from `pitch` that glides to `pitch * glide` over `length` seconds, starting
 * `delay` seconds from now, at `peak` volume, with `edge` of noise around `bright` times the pitch.
 */
function blip(
  audible: AudioContext,
  grain: AudioBuffer,
  { pitch, glide, length, peak, delay = 0, edge = 0.7, bright = 3 }: Blip,
) {
  const start = audible.currentTime + 0.002 + delay;
  const out = audible.createGain();
  out.gain.setValueAtTime(0, start);
  out.gain.linearRampToValueAtTime(peak, start + 0.0015);
  out.gain.exponentialRampToValueAtTime(0.0001, start + length);
  out.connect(audible.destination);

  // The body: a sine that glides, as a tap on something hollow.
  const tone = audible.createOscillator();
  tone.type = 'sine';
  tone.frequency.setValueAtTime(pitch, start);
  tone.frequency.exponentialRampToValueAtTime(pitch * glide, start + length * 0.8);
  tone.connect(out);

  // The edge: a few milliseconds of band-passed noise.
  const grit = audible.createBufferSource();
  grit.buffer = grain;
  const band = audible.createBiquadFilter();
  band.type = 'bandpass';
  band.frequency.value = Math.min(12000, pitch * bright);
  band.Q.value = 0.9;
  const edgeGain = audible.createGain();
  edgeGain.gain.setValueAtTime(edge, start);
  edgeGain.gain.exponentialRampToValueAtTime(0.001, start + 0.012);
  grit.connect(band).connect(edgeGain).connect(out);

  tone.start(start);
  tone.stop(start + length);
  grit.start(start);
  grit.stop(start + 0.015);
  tone.onended = () => out.disconnect();
}

type Blip = {
  pitch: number;
  glide: number;
  length: number;
  peak: number;
  delay?: number;
  edge?: number;
  bright?: number;
};

/** One card's use of the page's sounds. Call `dispose` when the card unmounts. */
export function clickSound() {
  users += 1;
  let disposed = false;

  /** The audio, woken, once a gesture has made it; null before. */
  const ready = () => {
    if (disposed || !context || !noise) return null;
    if (context.state === 'suspended') void context.resume();
    restSoon();
    return { audible: context, grain: noise };
  };

  return {
    /** The reader pressed or keyed the card: from now on, sounds may play. */
    unlock() {
      const audible = audio();
      if (audible?.state === 'suspended') void audible.resume();
      restSoon();
    },

    /** Ticks once, at `level` of the range (0 to 1): lower and quieter as it falls. */
    play(level: number) {
      const now = performance.now();
      if (now - lastTick < MIN_GAP) return;
      const on = ready();
      if (!on) return;
      lastTick = now;
      const share = Math.min(1, Math.max(0, level));
      blip(on.audible, on.grain, {
        pitch: 520 + 1180 * share,
        glide: 0.62,
        length: LENGTH,
        peak: 0.014 + 0.05 * share ** 1.3,
      });
    },

    /** The thumb reached the low end: a soft thud that falls. */
    low() {
      const on = ready();
      if (!on) return;
      blip(on.audible, on.grain, { pitch: 260, glide: 0.55, length: 0.16, peak: 0.07, edge: 0.4 });
    },

    /** The thumb reached the high end: two chimes, a fifth apart, the second a breath later. */
    high() {
      const on = ready();
      if (!on) return;
      blip(on.audible, on.grain, { pitch: 1320, glide: 0.98, length: 0.18, peak: 0.035 });
      blip(on.audible, on.grain, {
        pitch: 1980,
        glide: 0.98,
        length: 0.24,
        peak: 0.03,
        delay: 0.075,
        edge: 0.3,
      });
    },

    /** The vote: a short pop that rises. */
    vote() {
      const on = ready();
      if (!on) return;
      blip(on.audible, on.grain, { pitch: 640, glide: 1.5, length: 0.07, peak: 0.05, edge: 0.5 });
    },

    /** The card is gone; the last one closes the audio. */
    dispose() {
      if (disposed) return;
      disposed = true;
      users -= 1;
      if (users > 0 || !context) return;
      window.clearTimeout(restTimer);
      void context.close();
      context = null;
      noise = null;
    },
  };
}

export type ClickSound = ReturnType<typeof clickSound>;
```

### battery-slider/use-autoplay.ts

```tsx
'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;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```