# Volume Control

> Five volume controls that refuse to just be a slider: tilt, cannon, curling, dot-matrix, and whack-a-mole.

A shadcn/ui block from the [@uicapsule](https://uicapsule.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uicapsule/volume-control).

- Page, with a live preview: https://shadcn.exchange/r/uicapsule/volume-control
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uicapsule/volume-control-light-90c0ee0819c4f74d.png
- Registry item: https://uicapsule.com/r/volume-control.json
- Categories: controls, skeuomorphism, silly
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://uicapsule.com/r/volume-control.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `motion`

## Files

### `/cannon-track.tsx`

```tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { animate, AnimatePresence, motion, useMotionValue, useReducedMotion } from "motion/react";

import type { MotionValue } from "motion/react";

import { HUD_INNER_WIDTH } from "./macos-chrome";
import {
  arrowDelta,
  clamp,
  clampVolume,
  DETENT_STEP,
  snapVolume,
  VOLUME_MAX,
  VOLUME_MIN,
} from "./volume-scale";

const BOARD_WIDTH = HUD_INNER_WIDTH;
const BOARD_HEIGHT = 190;
const FLOOR_Y = BOARD_HEIGHT - 26;

const BALL_SIZE = 14;
/** Where the barrel ends and the ball leaves. Volume is read off the floor, so
 * the muzzle sits at the zero end of the scale. */
const MUZZLE = { x: 30, y: FLOOR_Y - 22 };

const GRAVITY = 1500;
/** Launch speed (px/s) per px of pull-back. */
const POWER_GAIN = 6.2;
const MAX_PULL = 120;
/** A twitch is not a shot. */
const MIN_PULL = 6;
/** Speed kept after the ball hits the floor, and the bite the floor takes out of
 * its forward motion. Cannonballs don't skip like stones. */
const BOUNCE = 0.42;
const FLOOR_DRAG = 0.66;
/** Below this the ball has stopped, whatever it thinks. */
const SETTLE_SPEED = 42;
const MAX_FLIGHT_SECONDS = 6;

const RESET_SPRING = { damping: 26, stiffness: 300, type: "spring" } as const;

interface Shot {
  x: number;
  y: number;
  vx: number;
  vy: number;
}

const floorToVolume = (x: number) => clamp((x / BOARD_WIDTH) * VOLUME_MAX, VOLUME_MIN, VOLUME_MAX);
const volumeToFloor = (volume: number) => (clampVolume(volume) / VOLUME_MAX) * BOARD_WIDTH;

/** The volume scale, painted on the floor where the ball has to land on it. Ticks
 * sit exactly on their value — the ball is scored against them, so they can't lie
 * — while the two end labels shuffle inwards to clear the board's rounded corner. */
const endLabelShift = (value: number) => {
  if (value === 0) {
    return "translate-x-2";
  }
  if (value === 100) {
    return "-translate-x-2.5";
  }
  return "";
};

const FloorScale = () => (
  <div aria-hidden className="absolute inset-x-0 bottom-0 h-[26px] border-t border-white/10">
    {[0, 25, 50, 75, 100].map((value) => (
      <span
        key={value}
        className="absolute top-0 flex -translate-x-1/2 flex-col items-center"
        style={{ left: volumeToFloor(value) }}
      >
        <span className="h-2 w-px bg-white/25" />
        <span
          className={`mt-0.5 text-[10px] tabular-nums text-neutral-500 ${endLabelShift(value)}`}
        >
          {value}
        </span>
      </span>
    ))}
    {Array.from({ length: VOLUME_MAX / DETENT_STEP + 1 }, (_, index) => index * DETENT_STEP)
      .filter((value) => value % 25 !== 0)
      .map((value) => (
        <span
          key={value}
          className="absolute top-0 h-1 w-px bg-white/10"
          style={{ left: volumeToFloor(value) }}
        />
      ))}
  </div>
);

/** One step of ballistics. Shared by the animated flight and the reduced-motion
 * fast-forward, so both land the ball in exactly the same place. */
const advance = (shot: Shot, dt: number): Shot => {
  const vy = shot.vy + GRAVITY * dt;
  let next = { vx: shot.vx, vy, x: shot.x + shot.vx * dt, y: shot.y + vy * dt };

  if (next.y >= FLOOR_Y) {
    next = {
      vx: next.vx * FLOOR_DRAG,
      vy: -Math.abs(next.vy) * BOUNCE,
      x: next.x,
      y: FLOOR_Y,
    };
  }
  return next;
};

interface CannonTrackProps {
  volume: MotionValue<number>;
}

/**
 * Artillery, as a volume control. Haul back off the muzzle to load angle and
 * power together, let go, and watch a cannonball arc across the scale. Wherever
 * it comes to rest is your new volume — no aiming line, no preview, no second
 * guess. Overshoot the board and the shot is simply wide: the volume doesn't
 * change, and you get to do it again.
 */
export const CannonTrack = ({ volume }: CannonTrackProps) => {
  const reduceMotion = useReducedMotion();
  const [phase, setPhase] = useState<"idle" | "aiming" | "firing">("idle");
  const [wide, setWide] = useState(false);

  const ballX = useMotionValue(MUZZLE.x);
  const ballY = useMotionValue(MUZZLE.y);
  /** Where the pointer has hauled the ball back to: it aims the barrel and sizes
   * the band, so it renders — but the shot is read on pointerup, and a render is
   * not guaranteed to have landed by then, hence the mirror. */
  const [pullVector, setPullVector] = useState({ x: 0, y: 0 });
  const pull = useRef({ x: 0, y: 0 });

  const frame = useRef(0);
  const wideTimer = useRef(0);
  const boardRef = useRef<HTMLDivElement>(null);

  // Written to the attribute rather than held in state, matching the other
  // controls: nothing about the reported value needs a render to land.
  useEffect(() => {
    const report = (value: number) =>
      boardRef.current?.setAttribute("aria-valuenow", String(Math.round(value)));
    report(volume.get());
    return volume.on("change", report);
  }, [volume]);

  useEffect(
    () => () => {
      cancelAnimationFrame(frame.current);
      window.clearTimeout(wideTimer.current);
    },
    [],
  );

  const resetBall = () => {
    void animate(ballX, MUZZLE.x, RESET_SPRING);
    void animate(ballY, MUZZLE.y, RESET_SPRING);
  };

  const land = (x: number) => {
    setPhase("idle");
    if (x > BOARD_WIDTH) {
      // Sailed clean off the board. The volume keeps whatever it had.
      setWide(true);
      window.clearTimeout(wideTimer.current);
      wideTimer.current = window.setTimeout(() => setWide(false), 1600);
      resetBall();
      return;
    }
    const landed = snapVolume(floorToVolume(x));
    volume.set(landed);
    void animate(ballX, volumeToFloor(landed), RESET_SPRING);
  };

  const fire = (vx: number, vy: number) => {
    if (reduceMotion) {
      // No arc, same maths: run the flight to its conclusion and report the result.
      let shot: Shot = { vx, vy, x: MUZZLE.x, y: MUZZLE.y };
      for (let t = 0; t < MAX_FLIGHT_SECONDS * 60; t += 1) {
        shot = advance(shot, 1 / 60);
        const resting = shot.y >= FLOOR_Y - 0.5 && Math.abs(shot.vy) < SETTLE_SPEED;
        if (resting || shot.x > BOARD_WIDTH) {
          break;
        }
      }
      land(shot.x);
      return;
    }

    setPhase("firing");
    let shot: Shot = { vx, vy, x: MUZZLE.x, y: MUZZLE.y };
    let last = performance.now();
    let elapsed = 0;

    const step = (now: number) => {
      const dt = Math.min((now - last) / 1000, 1 / 30);
      last = now;
      elapsed += dt;

      shot = advance(shot, dt);
      ballX.set(shot.x);
      ballY.set(shot.y);

      const grounded = shot.y >= FLOOR_Y - 0.5;
      const spent =
        grounded && Math.abs(shot.vy) < SETTLE_SPEED && Math.abs(shot.vx) < SETTLE_SPEED;
      if (spent || shot.x > BOARD_WIDTH + BALL_SIZE || elapsed > MAX_FLIGHT_SECONDS) {
        land(shot.x);
        return;
      }
      frame.current = requestAnimationFrame(step);
    };

    frame.current = requestAnimationFrame(step);
  };

  const pointerInBoard = (event: React.PointerEvent<HTMLDivElement>) => {
    const box = boardRef.current?.getBoundingClientRect();
    if (!box) {
      return { x: 0, y: 0 };
    }
    return { x: event.clientX - box.left, y: event.clientY - box.top };
  };

  const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
    if (phase === "firing") {
      return;
    }
    cancelAnimationFrame(frame.current);
    event.currentTarget.setPointerCapture(event.pointerId);
    setWide(false);
    setPhase("aiming");
    ballX.set(MUZZLE.x);
    ballY.set(MUZZLE.y);
    pull.current = { x: 0, y: 0 };
    setPullVector({ x: 0, y: 0 });
  };

  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
    if (phase !== "aiming") {
      return;
    }
    const point = pointerInBoard(event);
    // Pull is measured from the muzzle and capped, so the barrel can't be hauled
    // through the back of the cannon and the shot has a ceiling.
    const dx = point.x - MUZZLE.x;
    const dy = point.y - MUZZLE.y;
    const distance = Math.hypot(dx, dy);
    const scale = distance > MAX_PULL ? MAX_PULL / distance : 1;
    const vector = { x: dx * scale, y: dy * scale };

    pull.current = vector;
    setPullVector(vector);
    ballX.set(MUZZLE.x + vector.x);
    ballY.set(MUZZLE.y + vector.y);
  };

  const handlePointerUp = () => {
    if (phase !== "aiming") {
      return;
    }
    const vector = pull.current;
    const distance = Math.hypot(vector.x, vector.y);
    if (distance < MIN_PULL) {
      setPhase("idle");
      resetBall();
      setPullVector({ x: 0, y: 0 });
      return;
    }
    // Fired opposite the pull, like every slingshot ever built.
    fire(-vector.x * POWER_GAIN, -vector.y * POWER_GAIN);
    setPullVector({ x: 0, y: 0 });
  };

  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    const delta = arrowDelta(event.key);
    if (delta === 0) {
      return;
    }
    event.preventDefault();
    const next = clampVolume(snapVolume(volume.get()) + delta);
    volume.set(next);
    void animate(ballX, volumeToFloor(next), RESET_SPRING);
    void animate(ballY, FLOOR_Y, RESET_SPRING);
  };

  const pullDistance = Math.hypot(pullVector.x, pullVector.y);
  const barrelAngle =
    pullDistance > 0 ? (Math.atan2(-pullVector.y, -pullVector.x) * 180) / Math.PI : -45;
  const power = clamp(pullDistance / MAX_PULL, 0, 1);

  return (
    <div
      ref={boardRef}
      // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role, jsx-a11y/role-has-required-aria-props -- a physics toy, not a range input; aria-valuenow is written straight to the DOM every frame (see the effect above)
      role="slider"
      tabIndex={0}
      aria-label="Volume"
      aria-valuemin={VOLUME_MIN}
      aria-valuemax={VOLUME_MAX}
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={handlePointerUp}
      onPointerCancel={handlePointerUp}
      onKeyDown={handleKeyDown}
      style={{ height: BOARD_HEIGHT, touchAction: "none", width: BOARD_WIDTH }}
      className={`relative overflow-hidden rounded-2xl bg-neutral-950/80 outline-none focus-visible:ring-2 focus-visible:ring-white/70 ${
        phase === "aiming" ? "cursor-grabbing" : "cursor-crosshair"
      }`}
    >
      <FloorScale />

      {/* The barrel points wherever the shot is going, and swells with the load. */}
      <motion.span
        aria-hidden
        className="absolute origin-left rounded-r-[3px] bg-gradient-to-b from-neutral-400 to-neutral-600"
        style={{
          height: 10 + power * 3,
          left: MUZZLE.x - 16,
          rotate: barrelAngle,
          top: MUZZLE.y - 5,
          width: 34 + power * 6,
        }}
      />
      <span
        aria-hidden
        className="absolute size-7 rounded-full border-2 border-neutral-600 bg-neutral-800"
        style={{ left: MUZZLE.x - 26, top: MUZZLE.y - 8 }}
      />

      {/* The band you're hauling against — the only read on how hard this will go. */}
      {phase === "aiming" && pullDistance > MIN_PULL && (
        <svg aria-hidden className="pointer-events-none absolute inset-0 size-full">
          <line
            x1={MUZZLE.x}
            y1={MUZZLE.y}
            x2={MUZZLE.x + pullVector.x}
            y2={MUZZLE.y + pullVector.y}
            stroke={power > 0.85 ? "#fca5a5" : "#a78bfa"}
            strokeWidth={2 + power * 2}
            strokeLinecap="round"
          />
        </svg>
      )}

      <motion.span
        aria-hidden
        className="absolute rounded-full bg-gradient-to-b from-neutral-200 to-neutral-500 shadow-[0_2px_6px_rgba(0,0,0,0.6)]"
        style={{
          height: BALL_SIZE,
          left: -BALL_SIZE / 2,
          top: -BALL_SIZE / 2,
          width: BALL_SIZE,
          x: ballX,
          y: ballY,
        }}
      />

      <AnimatePresence>
        {wide && (
          <motion.p
            key="wide"
            initial={{ opacity: 0, y: 6 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0 }}
            className="absolute top-4 right-4 rounded-full bg-rose-500/15 px-3 py-1 text-[13px] text-rose-300"
          >
            Wide. Volume unchanged.
          </motion.p>
        )}
      </AnimatePresence>
    </div>
  );
};
```

### `/curling-track.tsx`

```tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { animate, motion, useMotionValue, useReducedMotion, useTransform } from "motion/react";

import type { MotionValue } from "motion/react";

import { HUD_INNER_WIDTH } from "./macos-chrome";
import { arrowDelta, clamp, clampVolume, snapVolume, VOLUME_MAX, VOLUME_MIN } from "./volume-scale";

const SHEET_WIDTH = HUD_INNER_WIDTH;
const SHEET_HEIGHT = 132;
const STONE_SIZE = 34;
const HACK_X = 8;
const TRAVEL = SHEET_WIDTH - STONE_SIZE;

/** Throwing speed (px/s) per px of draw-back. */
const THROW_GAIN = 7.4;
const MAX_DRAW = 110;
const MIN_DRAW = 6;

/** Friction on unswept ice, per second. Sweeping cuts it to a fraction of this —
 * that difference is the entire sport. */
const ICE_FRICTION = 1.35;
const SWEPT_FRICTION = 0.42;
/** How fast a burst of sweeping decays back to nothing. Stop scrubbing and the
 * ice pebbles back over within about a third of a second. */
const SWEEP_DECAY = 3.2;
/** Pointer px of scrubbing needed to fully polish the ice ahead of the stone. */
const SWEEP_FULL = 260;
/** Sweeping only counts within this many px of the stone. Waving the broom at the
 * far end of the sheet does nothing, as in life. */
const BROOM_REACH = 90;

const BACKBOARD = 0.3;
const SETTLE_SPEED = 12;
const MAX_SLIDE_SECONDS = 8;

const SETTLE_SPRING = { damping: 30, stiffness: 380, type: "spring" } as const;
const RETURN_SPRING = { damping: 24, stiffness: 220, type: "spring" } as const;

const stoneToVolume = (x: number) => clamp((x / TRAVEL) * VOLUME_MAX, VOLUME_MIN, VOLUME_MAX);
const volumeToStone = (volume: number) => (clampVolume(volume) / VOLUME_MAX) * TRAVEL;

/** Centre of the house, in sheet px — pulled in from the 100 mark so its rings
 * stay on the ice instead of falling off the end of it. */
const HOUSE_X = volumeToStone(92) + STONE_SIZE / 2;

/** The scale, printed on the ice. */
const SheetScale = () => (
  <div aria-hidden className="absolute inset-x-0 bottom-0 h-5">
    {[0, 25, 50, 75, 100].map((value) => (
      <span
        key={value}
        className="absolute bottom-0 flex -translate-x-1/2 flex-col items-center"
        style={{ left: volumeToStone(value) + STONE_SIZE / 2 }}
      >
        <span className="h-2 w-px bg-sky-100/30" />
        <span className="text-[10px] tabular-nums text-sky-100/40">{value}</span>
      </span>
    ))}
  </div>
);

interface CurlingTrackProps {
  volume: MotionValue<number>;
}

/**
 * Volume by curling. Draw the stone back off the hack, release, and it goes out
 * onto the ice with whatever weight you gave it — and then you're not done. While
 * it's travelling you can *sweep*: scrub the pointer back and forth just ahead of
 * the stone and the ice polishes, friction drops, and the stone carries further.
 * Stop sweeping and it grabs. Where it finally sits is the volume.
 */
export const CurlingTrack = ({ volume }: CurlingTrackProps) => {
  const reduceMotion = useReducedMotion();
  const [phase, setPhase] = useState<"idle" | "drawing" | "sliding">("idle");

  const stoneX = useMotionValue(volumeToStone(volume.get()));
  /** 0–1. How polished the ice under the broom is right now. */
  const sweep = useMotionValue(0);
  /** Where the broom is, in sheet px. Only meaningful while sweeping. */
  const broomX = useMotionValue(-100);
  const [drawing, setDrawing] = useState(0);

  const velocity = useRef(0);
  const frame = useRef(0);
  const drawOrigin = useRef(0);
  /** Draw distance, mirrored out of state: the throw is read on pointerup, and a
   * render is not guaranteed to have landed by then. */
  const drawn = useRef(0);
  const lastPointer = useRef<number | null>(null);
  const sheetRef = useRef<HTMLDivElement>(null);

  // Deliberately NOT mirrored off the stone's position: hauling the stone back
  // behind the hack would drag the volume to zero on the way, and the throw would
  // start from a lie. The volume only follows a stone that's actually travelling.

  // A travelling stone moves the volume every frame; the attribute is written
  // directly so none of that turns into a render.
  useEffect(() => {
    const report = (value: number) =>
      sheetRef.current?.setAttribute("aria-valuenow", String(Math.round(value)));
    report(volume.get());
    return volume.on("change", report);
  }, [volume]);

  useEffect(() => () => cancelAnimationFrame(frame.current), []);

  const pointerInSheet = (event: React.PointerEvent<HTMLDivElement>) => {
    const box = sheetRef.current?.getBoundingClientRect();
    return box ? event.clientX - box.left : 0;
  };

  const settle = (x: number) => {
    velocity.current = 0;
    setPhase("idle");
    lastPointer.current = null;
    void animate(sweep, 0, { duration: 0.3 });
    broomX.set(-100);
    const landed = snapVolume(stoneToVolume(x));
    volume.set(landed);
    void animate(stoneX, volumeToStone(landed), SETTLE_SPRING);
  };

  const deliver = (speed: number) => {
    velocity.current = speed;
    setPhase("sliding");
    // The stone leaves from the hack, not from wherever your hand hauled it back
    // to. Releasing from behind the line and letting the wall catch it would eat
    // the throw before it started.
    stoneX.set(HACK_X);

    let last = performance.now();
    let elapsed = 0;

    const step = (now: number) => {
      const dt = Math.min((now - last) / 1000, 1 / 30);
      last = now;
      elapsed += dt;

      // The broom's effect is local: polished ice only helps while the stone is
      // actually on it.
      const swept = sweep.get();
      const friction = ICE_FRICTION + (SWEPT_FRICTION - ICE_FRICTION) * swept;

      let v = velocity.current * Math.exp(-friction * dt);
      let x = stoneX.get() + v * dt;

      // Walls only stop a stone travelling into them — a stone leaving the hack is
      // moving away from the near wall and must be allowed to go.
      if (x < 0 && v < 0) {
        x = 0;
        v = 0;
      } else if (x > TRAVEL && v > 0) {
        // The backboard. Hit it hard enough and you come back down the sheet.
        x = TRAVEL - (x - TRAVEL);
        v = -v * BACKBOARD;
      }

      stoneX.set(x);
      // The stone in motion IS the volume: the menu bar climbs with it the whole
      // way down the sheet, and drops again if it comes back off the backboard.
      volume.set(snapVolume(stoneToVolume(x)));
      velocity.current = v;
      sweep.set(swept * Math.exp(-SWEEP_DECAY * dt));

      if (Math.abs(v) < SETTLE_SPEED || elapsed > MAX_SLIDE_SECONDS) {
        settle(x);
        return;
      }
      frame.current = requestAnimationFrame(step);
    };

    frame.current = requestAnimationFrame(step);
  };

  const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
    if (phase === "sliding" || reduceMotion) {
      return;
    }
    cancelAnimationFrame(frame.current);
    event.currentTarget.setPointerCapture(event.pointerId);
    drawOrigin.current = event.clientX;
    drawn.current = 0;
    stoneX.set(HACK_X);
    setPhase("drawing");
    setDrawing(0);
  };

  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
    if (phase === "drawing") {
      // Only backwards counts. Shoving the stone forwards by hand is not a throw.
      const distance = clamp(drawOrigin.current - event.clientX, 0, MAX_DRAW);
      drawn.current = distance;
      setDrawing(distance);
      stoneX.set(clamp(HACK_X - distance * 0.28, -24, TRAVEL));
      return;
    }
    if (phase !== "sliding") {
      return;
    }

    // Sweeping: raw scrubbing distance, but only if the broom is near the stone.
    const at = pointerInSheet(event);
    broomX.set(at);
    const previous = lastPointer.current;
    lastPointer.current = at;
    if (previous === null) {
      return;
    }

    const stoneCentre = stoneX.get() + STONE_SIZE / 2;
    if (Math.abs(at - stoneCentre) > BROOM_REACH) {
      return;
    }

    const scrubbed = Math.abs(at - previous);
    sweep.set(clamp(sweep.get() + scrubbed / SWEEP_FULL, 0, 1));
  };

  const handlePointerUp = () => {
    if (phase !== "drawing") {
      return;
    }
    const distance = drawn.current;
    if (distance < MIN_DRAW) {
      setPhase("idle");
      setDrawing(0);
      // No throw: the stone goes back to where the volume already was.
      void animate(stoneX, volumeToStone(volume.get()), RETURN_SPRING);
      return;
    }
    lastPointer.current = null;
    setDrawing(0);
    deliver(distance * THROW_GAIN);
  };

  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    const delta = arrowDelta(event.key);
    if (delta === 0) {
      return;
    }
    event.preventDefault();
    cancelAnimationFrame(frame.current);
    settle(volumeToStone(clampVolume(snapVolume(volume.get()) + delta)));
  };

  const stoneSpin = useTransform(stoneX, (x) => (x / (Math.PI * STONE_SIZE)) * 220);
  const broomOpacity = useTransform(sweep, [0, 0.15], [0, 1]);
  const iceGlow = useTransform(sweep, [0, 1], [0, 0.5]);
  const drawPower = clamp(drawing / MAX_DRAW, 0, 1);

  return (
    <div
      ref={sheetRef}
      // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role, jsx-a11y/role-has-required-aria-props -- a physics toy, not a range input; aria-valuenow is written straight to the DOM every frame (see the effect above)
      role="slider"
      tabIndex={0}
      aria-label="Volume"
      aria-valuemin={VOLUME_MIN}
      aria-valuemax={VOLUME_MAX}
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={handlePointerUp}
      onPointerCancel={handlePointerUp}
      onKeyDown={handleKeyDown}
      style={{ height: SHEET_HEIGHT, touchAction: "none", width: SHEET_WIDTH }}
      className={`relative overflow-hidden rounded-2xl bg-gradient-to-b from-sky-100/10 to-sky-50/[0.04] outline-none focus-visible:ring-2 focus-visible:ring-white/70 ${
        phase === "drawing" ? "cursor-grabbing" : "cursor-grab"
      }`}
    >
      {/* The house, painted at the loud end — the target nobody asked for. It sits
          a shade inside 100 so the rings don't run off the end of the sheet. */}
      <span
        aria-hidden
        className="absolute top-1/2 size-20 -translate-x-1/2 -translate-y-1/2 rounded-full border border-sky-300/25"
        style={{ left: HOUSE_X }}
      />
      <span
        aria-hidden
        className="absolute top-1/2 size-10 -translate-x-1/2 -translate-y-1/2 rounded-full border border-rose-300/30 bg-rose-400/10"
        style={{ left: HOUSE_X }}
      />

      <SheetScale />

      {/* Polished ice: a wash of light under the broom while it's working. */}
      <motion.span
        aria-hidden
        className="absolute inset-y-0 w-40 -translate-x-1/2 bg-[radial-gradient(ellipse_at_center,rgba(186,230,253,0.9),transparent_70%)]"
        style={{ left: broomX, opacity: iceGlow }}
      />

      <motion.span
        aria-hidden
        className="absolute top-1/2 -translate-y-1/2 rounded-full bg-gradient-to-b from-neutral-300 to-neutral-500 shadow-[0_3px_10px_rgba(0,0,0,0.55)]"
        style={{ height: STONE_SIZE, rotate: stoneSpin, width: STONE_SIZE, x: stoneX }}
      >
        <span className="absolute inset-[6px] rounded-full border border-neutral-600/60 bg-neutral-700" />
        <span className="absolute inset-x-[13px] top-[3px] h-2 rounded-full bg-rose-400/80" />
      </motion.span>

      {/* The broom only exists while you're actually sweeping with it. */}
      <motion.span
        aria-hidden
        className="absolute top-1/2 h-9 w-2 -translate-x-1/2 -translate-y-1/2 rotate-12 rounded-sm bg-amber-200/90 shadow-[0_0_10px_rgba(253,230,138,0.5)]"
        style={{ left: broomX, opacity: broomOpacity }}
      />

      {phase === "drawing" && drawing > MIN_DRAW && (
        <div
          aria-hidden
          className="absolute right-4 bottom-3 left-4 h-1 overflow-hidden rounded-full bg-white/10"
        >
          <div
            className="h-full rounded-full bg-gradient-to-r from-sky-300 to-rose-300"
            style={{ width: `${drawPower * 100}%` }}
          />
        </div>
      )}
    </div>
  );
};
```

### `/dots-track.tsx`

```tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { EraserIcon, ScanLineIcon } from "lucide-react";

import type { MotionValue } from "motion/react";

import { clampVolume } from "./volume-scale";

const COLS = 5;
const ROWS = 7;
const CELL_SIZE = COLS * ROWS;
const DOT = 13;
const DOT_GAP = 5;

/** The dots, as fixed positions rather than an anonymous array — so each one can
 * carry its own key and say where it lives out loud for a screen reader. */
const POSITIONS = Array.from({ length: CELL_SIZE }, (_, index) => ({
  column: (index % COLS) + 1,
  index,
  row: Math.floor(index / COLS) + 1,
}));

/** A 5×7 glyph for every digit, indexed by digit — the machine's entire idea of
 * what numbers look like. It matches whatever you draw against these and picks a winner, no matter
 * how little your drawing deserves one. */
const GLYPHS: readonly (readonly string[])[] = [
  ["01110", "10001", "10011", "10101", "11001", "10001", "01110"],
  ["00100", "01100", "00100", "00100", "00100", "00100", "01110"],
  ["01110", "10001", "00001", "00010", "00100", "01000", "11111"],
  ["11111", "00010", "00100", "00010", "00001", "10001", "01110"],
  ["00010", "00110", "01010", "10010", "11111", "00010", "00010"],
  ["11111", "10000", "11110", "00001", "00001", "10001", "01110"],
  ["00110", "01000", "10000", "11110", "10001", "10001", "01110"],
  ["11111", "00001", "00010", "00100", "01000", "01000", "01000"],
  ["01110", "10001", "10001", "01110", "10001", "10001", "01110"],
  ["01110", "10001", "10001", "01111", "00001", "00010", "01100"],
];

const GLYPH_CELLS: readonly (readonly boolean[])[] = GLYPHS.map((rows) =>
  Array.from(rows.join(""), (bit) => bit === "1"),
);

const EMPTY_CELL = (): boolean[] => Array.from({ length: CELL_SIZE }, () => false);

/** How many lit dots it takes before the machine bothers looking at a cell. Under
 * this, the cell is blank and the digit is a zero. */
const INK_THRESHOLD = 3;

/**
 * Nearest glyph wins, full stop. There is no "I'm not sure" — every drawing is
 * read as *something*, and a lazy 7 becoming a 1 is the entire point of the
 * exercise. Ties go to the lower digit, purely so it's deterministic.
 */
const readCell = (cells: boolean[]): number => {
  const ink = cells.filter(Boolean).length;
  if (ink < INK_THRESHOLD) {
    return 0;
  }

  let best = 0;
  let bestScore = -1;
  for (const [digit, glyph] of GLYPH_CELLS.entries()) {
    let score = 0;
    for (let index = 0; index < CELL_SIZE; index += 1) {
      if (cells[index] === glyph[index]) {
        score += 1;
      }
    }
    if (score > bestScore) {
      bestScore = score;
      best = digit;
    }
  }
  return best;
};

const SCAN_MS = 620;

const Verdict = ({ scanning, verdict }: { scanning: boolean; verdict: number | null }) => {
  if (scanning) {
    return <span className="text-neutral-600">scanning…</span>;
  }
  if (verdict === null) {
    return <span className="text-neutral-700">—</span>;
  }
  return verdict;
};

interface DotCellProps {
  label: "tens" | "ones";
  cells: boolean[];
  onDown: (cell: "tens" | "ones", index: number, lit: boolean) => void;
  onEnter: (cell: "tens" | "ones", index: number) => void;
}

const DotCell = ({ label, cells, onDown, onEnter }: DotCellProps) => (
  <div
    className="grid"
    style={{
      gap: DOT_GAP,
      gridTemplateColumns: `repeat(${COLS}, ${DOT}px)`,
      touchAction: "none",
    }}
  >
    {POSITIONS.map((dot) => {
      const lit = cells[dot.index] ?? false;
      return (
        <button
          key={`${label}-${dot.row}-${dot.column}`}
          type="button"
          aria-pressed={lit}
          aria-label={`${label} digit, row ${dot.row}, column ${dot.column}`}
          onPointerDown={() => onDown(label, dot.index, lit)}
          onPointerEnter={() => onEnter(label, dot.index)}
          style={{ height: DOT, margin: 0, width: DOT }}
          className={`rounded-full outline-none transition-colors duration-75 focus-visible:ring-2 focus-visible:ring-white/70 ${
            lit
              ? "bg-emerald-300 shadow-[0_0_8px_rgba(110,231,183,0.65)]"
              : "bg-neutral-800 hover:bg-neutral-700"
          }`}
        />
      );
    })}
  </div>
);

interface DotsTrackProps {
  volume: MotionValue<number>;
}

/**
 * The machine reads dot-matrix. It does not read sliders, it does not read
 * numbers you type — it reads dots. So draw the volume you want, digit by digit,
 * and it will squint at your handwriting and commit to whatever it decides you
 * meant. Two cells, which is to say the loudest this thing goes is 99.
 */
export const DotsTrack = ({ volume }: DotsTrackProps) => {
  const reduceMotion = useReducedMotion();
  const [tens, setTens] = useState<boolean[]>(EMPTY_CELL);
  const [ones, setOnes] = useState<boolean[]>(EMPTY_CELL);
  const [scanning, setScanning] = useState(false);
  const [verdict, setVerdict] = useState<number | null>(null);

  /** What we're painting for the length of this drag: on, or off. Set by whichever
   * dot you pressed first, so a drag never flickers dots on and off under itself. */
  const painting = useRef<boolean | null>(null);
  const scanTimer = useRef(0);

  useEffect(() => () => window.clearTimeout(scanTimer.current), []);

  const paint = (cell: "tens" | "ones", index: number, value: boolean) => {
    const setter = cell === "tens" ? setTens : setOnes;
    setter((current) => current.map((lit, position) => (position === index ? value : lit)));
    setVerdict(null);
  };

  const handleDotDown = (cell: "tens" | "ones", index: number, lit: boolean) => {
    painting.current = !lit;
    paint(cell, index, !lit);
  };

  const handleDotEnter = (cell: "tens" | "ones", index: number) => {
    if (painting.current === null) {
      return;
    }
    paint(cell, index, painting.current);
  };

  const clear = () => {
    setTens(EMPTY_CELL());
    setOnes(EMPTY_CELL());
    setVerdict(null);
  };

  const read = () => {
    const value = clampVolume(readCell(tens) * 10 + readCell(ones));

    if (reduceMotion) {
      setVerdict(value);
      volume.set(value);
      return;
    }

    // The scan is theatre, but it's the theatre that sells the misread: you watch
    // it look, and then you watch it be wrong with total confidence.
    setScanning(true);
    setVerdict(null);
    scanTimer.current = window.setTimeout(() => {
      setScanning(false);
      setVerdict(value);
      volume.set(value);
    }, SCAN_MS);
  };

  return (
    <div
      onPointerUp={() => {
        painting.current = null;
      }}
      onPointerLeave={() => {
        painting.current = null;
      }}
      className="flex items-center gap-5"
    >
      <div className="relative flex gap-3 rounded-2xl bg-neutral-950/80 p-3">
        <DotCell label="tens" cells={tens} onDown={handleDotDown} onEnter={handleDotEnter} />
        <DotCell label="ones" cells={ones} onDown={handleDotDown} onEnter={handleDotEnter} />

        <AnimatePresence>
          {scanning && (
            <motion.span
              key="scan"
              aria-hidden
              className="pointer-events-none absolute inset-y-2 w-10 rounded-full bg-gradient-to-r from-transparent via-emerald-300/50 to-transparent blur-[2px]"
              initial={{ left: 0, opacity: 0 }}
              animate={{ left: "calc(100% - 40px)", opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={{ duration: SCAN_MS / 1000, ease: "linear" }}
            />
          )}
        </AnimatePresence>
      </div>

      <div className="flex flex-1 flex-col items-start gap-3">
        <div aria-live="polite" className="min-h-[52px]">
          <p className="text-[11px] tracking-wide text-neutral-500 uppercase">Reads as</p>
          <p className="text-[30px] leading-tight font-semibold tabular-nums text-emerald-300">
            <Verdict scanning={scanning} verdict={verdict} />
          </p>
        </div>

        <div className="flex gap-2">
          <button
            type="button"
            onClick={read}
            disabled={scanning}
            className="flex items-center gap-1.5 rounded-full border border-emerald-400/50 bg-emerald-400/10 px-3 py-1.5 text-[13px] text-neutral-100 outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/60 disabled:opacity-50"
          >
            <ScanLineIcon className="size-3.5 text-emerald-300" />
            Read
          </button>
          <button
            type="button"
            onClick={clear}
            className="flex items-center gap-1.5 rounded-full border border-white/15 px-3 py-1.5 text-[13px] text-neutral-300 outline-none hover:bg-white/5 focus-visible:ring-2 focus-visible:ring-white/60"
          >
            <EraserIcon className="size-3.5 text-neutral-400" />
            Clear
          </button>
        </div>
      </div>
    </div>
  );
};
```

### `/macos-chrome.tsx`

```tsx
"use client";

import { useRef } from "react";
import { motion, useMotionValueEvent, useTransform } from "motion/react";
import {
  BatteryMediumIcon,
  SearchIcon,
  Volume1Icon,
  Volume2Icon,
  VolumeIcon,
  VolumeOffIcon,
  WifiIcon,
} from "lucide-react";

import type { MotionValue } from "motion/react";
import type { ReactNode } from "react";

import { clampVolume, speakerTier, VOLUME_MAX } from "./volume-scale";

/** Every control is built to fit inside the HUD with 20px of air on each side, so
 * this one number sets the groove's length, the cannon's range, and the sheet of
 * ice. Widen it and the whole set widens with it. */
export const HUD_WIDTH = 520;
export const HUD_INNER_WIDTH = HUD_WIDTH - 40;

const TIERS = ["muted", "low", "mid", "high"] as const;

const TIER_ICON = {
  high: Volume2Icon,
  low: VolumeIcon,
  mid: Volume1Icon,
  muted: VolumeOffIcon,
} as const;

const TierIcon = ({
  tier,
  volume,
}: {
  tier: (typeof TIERS)[number];
  volume: MotionValue<number>;
}) => {
  const Icon = TIER_ICON[tier];
  const opacity = useTransform(volume, (value) => (speakerTier(value) === tier ? 1 : 0));
  return (
    <motion.span aria-hidden className="absolute inset-0" style={{ opacity }}>
      <Icon className="size-[22px] text-neutral-100" />
    </motion.span>
  );
};

/**
 * Four glyphs stacked, cross-fading on the volume value. A tier change is an icon
 * swap, and an icon swap is a re-render — so instead the icons all stay mounted
 * and opacity does the work, off the motion value, on the compositor.
 */
const SpeakerGlyph = ({ volume }: { volume: MotionValue<number> }) => (
  <span className="relative flex size-[22px] items-center justify-center">
    {TIERS.map((tier) => (
      <TierIcon key={tier} tier={tier} volume={volume} />
    ))}
  </span>
);

interface MacosChromeProps {
  /** Volume 0–100, owned by the shell. The menu bar follows it frame by frame. */
  volume: MotionValue<number>;
  open: boolean;
  onToggle: () => void;
  /** The HUD, anchored under the speaker icon. */
  hud: ReactNode;
}

/**
 * The desktop the controls live on: a menu bar running off the left edge of the
 * frame, with the speaker icon as the one thing you can actually click. It opens
 * the HUD and, from then on, only reports — it never sets anything itself.
 */
export const MacosChrome = ({ volume, open, onToggle, hud }: MacosChromeProps) => (
  <div className="flex items-center gap-6 select-none">
    <div aria-hidden className="flex items-center gap-6 text-[15px] whitespace-nowrap">
      <span className="font-semibold text-neutral-100">Music</span>
      <span className="text-neutral-300">File</span>
      <span className="text-neutral-300">Edit</span>
      <span className="text-neutral-300">Controls</span>
      <span className="text-neutral-300">Window</span>
    </div>

    <div className="ml-auto flex shrink-0 items-center gap-5">
      <BatteryMediumIcon aria-hidden className="size-6 text-neutral-300" />
      <WifiIcon aria-hidden className="size-[19px] text-neutral-300" />

      <div className="relative">
        <button
          type="button"
          onClick={onToggle}
          aria-expanded={open}
          aria-label="Sound"
          className={`flex size-9 cursor-pointer items-center justify-center rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-white/70 ${
            open ? "bg-white/15" : "hover:bg-white/10"
          }`}
        >
          <SpeakerGlyph volume={volume} />
        </button>

        {hud}
      </div>

      <SearchIcon aria-hidden className="size-[19px] text-neutral-300" />
      <span aria-hidden className="text-[15px] whitespace-nowrap text-neutral-100">
        Tue 9:41
      </span>
    </div>
  </div>
);

interface VolumeHudProps {
  volume: MotionValue<number>;
  /** What the HUD calls the control it's showing. */
  title: string;
  /** A line of instruction under the title — the only help you get. */
  hint: string;
  children: ReactNode;
}

/**
 * The HUD itself: a readout that reports whatever the control below it decides,
 * and no way to set the volume directly. The number and the meter are written
 * straight to the DOM — a marble rolling or a stone sliding moves the volume
 * every frame, and none of that is worth a React render.
 */
export const VolumeHud = ({ volume, title, hint, children }: VolumeHudProps) => {
  const readoutRef = useRef<HTMLSpanElement>(null);
  useMotionValueEvent(volume, "change", (value) => {
    if (readoutRef.current) {
      readoutRef.current.textContent = String(Math.round(value));
    }
  });
  const meterScale = useTransform(volume, (value) => clampVolume(value) / VOLUME_MAX);

  return (
    <div
      style={{ width: HUD_WIDTH }}
      className="rounded-[22px] border border-white/10 bg-neutral-800/95 p-5 shadow-2xl shadow-black/60 backdrop-blur-xl"
    >
      <div className="mb-1 flex items-baseline justify-between gap-3">
        <p className="text-[15px] font-medium text-neutral-100">{title}</p>
        <p aria-live="polite" className="text-[15px] tabular-nums text-neutral-400">
          <span ref={readoutRef} className="text-neutral-100">
            {Math.round(volume.get())}
          </span>
          <span className="text-neutral-500"> / {VOLUME_MAX}</span>
        </p>
      </div>

      <p className="mb-3 text-[13px] text-neutral-500">{hint}</p>

      {/* The honest slider, demoted to a read-only meter. It shows you exactly
          where you are and offers no way to change it. */}
      <div aria-hidden className="mb-4 h-1.5 overflow-hidden rounded-full bg-neutral-700/70">
        <motion.div
          className="h-full origin-left rounded-full bg-neutral-100"
          style={{ scaleX: meterScale }}
        />
      </div>

      {children}
    </div>
  );
};
```

### `/plus-minus-track.tsx`

```tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { MinusIcon, PlusIcon } from "lucide-react";

import type { MotionValue } from "motion/react";

import { HUD_INNER_WIDTH } from "./macos-chrome";
import { clampVolume, DETENT_STEP, snapVolume, VOLUME_MAX, VOLUME_MIN } from "./volume-scale";

const FIELD_WIDTH = HUD_INNER_WIDTH;
const FIELD_HEIGHT = 150;
const COLS = 5;
const ROWS = 3;
const HOLES = COLS * ROWS;

/** What a hit is worth. Both directions cost the same — the minus is not a
 * penalty, it's just the other button, and it counts exactly as much. */
const STEP = DETENT_STEP;

/** How long a button stays up at volume 0, and at volume 100. The louder it gets,
 * the less time you have — the last twenty points are a scramble. */
const SHOW_MS_LOUD = 520;
const SHOW_MS_QUIET = 1000;
/** Pill size shrinks the same way. */
const SIZE_QUIET = 54;
const SIZE_LOUD = 38;

/** How often a minus comes up instead of a plus. Enough to keep you honest. */
const MINUS_ODDS = 0.38;

const POP = { damping: 26, stiffness: 520, type: "spring" } as const;

interface Mole {
  id: number;
  hole: number;
  kind: "plus" | "minus";
}

const lerp = (from: number, to: number, t: number) => from + (to - from) * t;

const keyDelta = (key: string) => {
  if (key === "ArrowRight") {
    return STEP;
  }
  if (key === "ArrowLeft") {
    return -STEP;
  }
  return 0;
};

const StaticButton = ({ kind, onClick }: { kind: "plus" | "minus"; onClick: () => void }) => (
  <button
    type="button"
    onClick={onClick}
    aria-label={kind === "plus" ? "Louder" : "Quieter"}
    className="flex size-11 items-center justify-center rounded-full border border-white/15 bg-neutral-800 text-neutral-100 outline-none hover:bg-neutral-700 focus-visible:ring-2 focus-visible:ring-white/70"
  >
    {kind === "plus" ? <PlusIcon className="size-5" /> : <MinusIcon className="size-5" />}
  </button>
);

interface PlusMinusTrackProps {
  volume: MotionValue<number>;
}

/**
 * The + and − are still there. They just won't hold still.
 *
 * They surface for a moment, somewhere in the grid, and drop back down whether or
 * not you got to them. Hit the + and you gain five; hit the − — and you will,
 * because it looks exactly as inviting — and you lose five. The louder it gets,
 * the faster they duck and the smaller they are, so the last stretch to 100 is a
 * fight you're supposed to lose at least once.
 */
export const PlusMinusTrack = ({ volume }: PlusMinusTrackProps) => {
  const reduceMotion = useReducedMotion();
  const [mole, setMole] = useState<Mole | null>(null);
  const [level, setLevel] = useState(() => Math.round(volume.get()));

  const nextId = useRef(0);
  const timer = useRef(0);
  const lastHole = useRef(-1);
  /** The loop reads the volume, and the volume changes on every hit — so it reads
   * it from a ref rather than closing over a stale render's copy. `apply` is the
   * only writer and updates both, so the two never drift. */
  const levelRef = useRef(level);
  /** A hit has to be able to summon the next pop, and it lives outside the effect
   * that defines the loop. This is the doorbell. */
  const popRef = useRef(() => {
    /* empty */
  });
  /** Pops already scored, so an exiting pill can't be scored twice. */
  const whacked = useRef(new Set<number>());

  // Reduced motion gets what it should have had all along: two buttons that stay
  // where you left them.
  useEffect(() => {
    if (reduceMotion) {
      return;
    }

    const pop = () => {
      // Never the same hole twice running — a pill that reappears under a resting
      // cursor is a gift, and this control does not give gifts.
      const drift = 1 + Math.floor(Math.random() * (HOLES - 1));
      const hole =
        lastHole.current < 0
          ? Math.floor(Math.random() * HOLES)
          : (lastHole.current + drift) % HOLES;
      lastHole.current = hole;

      const kind = Math.random() < MINUS_ODDS ? "minus" : "plus";
      nextId.current += 1;
      setMole({ hole, id: nextId.current, kind });

      // Louder means less time to react. The window closes as you climb.
      const loudness = clampVolume(levelRef.current) / VOLUME_MAX;
      timer.current = window.setTimeout(
        () => {
          setMole(null);
          timer.current = window.setTimeout(pop, 180);
        },
        lerp(SHOW_MS_QUIET, SHOW_MS_LOUD, loudness),
      );
    };

    popRef.current = pop;
    pop();
    return () => window.clearTimeout(timer.current);
  }, [reduceMotion]);

  const apply = (delta: number) => {
    const next = clampVolume(snapVolume(levelRef.current) + delta);
    levelRef.current = next;
    setLevel(next);
    volume.set(next);
  };

  const whack = (hit: Mole) => {
    // A pill is still on screen — and still clickable — for as long as its exit
    // animation runs. Without this, a fast enough hand scores the same pop over
    // and over on its way down the hole.
    if (whacked.current.has(hit.id)) {
      return;
    }
    whacked.current.add(hit.id);

    window.clearTimeout(timer.current);
    setMole(null);
    apply(hit.kind === "plus" ? STEP : -STEP);
    // The next one comes up quicker than if this had timed out. No rest.
    timer.current = window.setTimeout(() => popRef.current(), 200);
  };

  if (reduceMotion) {
    return (
      <div className="flex items-center justify-center gap-4">
        <StaticButton kind="minus" onClick={() => apply(-STEP)} />
        <span
          // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- a range input would be the honest slider this piece refuses to be; the keyboard contract is the same
          role="slider"
          tabIndex={0}
          aria-label="Volume"
          aria-valuemin={VOLUME_MIN}
          aria-valuemax={VOLUME_MAX}
          aria-valuenow={level}
          onKeyDown={(event) => {
            const delta = keyDelta(event.key);
            if (delta === 0) {
              return;
            }
            event.preventDefault();
            apply(delta);
          }}
          className="text-[28px] tabular-nums text-neutral-100 outline-none focus-visible:ring-2 focus-visible:ring-white/70"
        >
          {level}
        </span>
        <StaticButton kind="plus" onClick={() => apply(STEP)} />
      </div>
    );
  }

  const loudness = clampVolume(level) / VOLUME_MAX;
  const size = lerp(SIZE_QUIET, SIZE_LOUD, loudness);

  return (
    <div
      // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- the whack-a-mole field is the slider; a range input can't host the buttons
      role="slider"
      tabIndex={0}
      aria-label="Volume"
      aria-valuemin={VOLUME_MIN}
      aria-valuemax={VOLUME_MAX}
      aria-valuenow={level}
      onKeyDown={(event) => {
        const delta = keyDelta(event.key);
        if (delta === 0) {
          return;
        }
        // The keyboard is the cheat code: the buttons stop running from you.
        event.preventDefault();
        apply(delta);
      }}
      style={{ height: FIELD_HEIGHT, width: FIELD_WIDTH }}
      className="relative grid rounded-2xl bg-neutral-950/80 p-2 outline-none focus-visible:ring-2 focus-visible:ring-white/70"
    >
      <div
        className="grid size-full"
        style={{
          gridTemplateColumns: `repeat(${COLS}, 1fr)`,
          gridTemplateRows: `repeat(${ROWS}, 1fr)`,
        }}
      >
        {Array.from({ length: HOLES }, (_, hole) => (
          <div key={hole} className="relative flex items-center justify-center">
            <span aria-hidden className="size-2 rounded-full bg-white/[0.06]" />

            <AnimatePresence>
              {mole?.hole === hole && (
                <motion.button
                  key={mole.id}
                  type="button"
                  aria-label={mole.kind === "plus" ? "Louder" : "Quieter"}
                  onPointerDown={() => whack(mole)}
                  initial={{ opacity: 0, scale: 0.4, y: 10 }}
                  animate={{ opacity: 1, scale: 1, y: 0 }}
                  exit={{ opacity: 0, scale: 0.5, y: 8 }}
                  transition={POP}
                  style={{ height: size, width: size }}
                  className={`absolute flex cursor-pointer items-center justify-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-white/70 ${
                    mole.kind === "plus"
                      ? "bg-emerald-400/90 shadow-[0_0_18px_rgba(52,211,153,0.45)]"
                      : "bg-rose-400/90 shadow-[0_0_18px_rgba(251,113,133,0.45)]"
                  }`}
                >
                  {mole.kind === "plus" ? (
                    <PlusIcon className="size-5 text-neutral-950" />
                  ) : (
                    <MinusIcon className="size-5 text-neutral-950" />
                  )}
                </motion.button>
              )}
            </AnimatePresence>
          </div>
        ))}
      </div>
    </div>
  );
};
```

### `/preview.tsx`

```tsx
"use client";

import { useState } from "react";
import { ChevronDownIcon } from "lucide-react";

import type { VolumeVariant } from "./volume-control";

import { VolumeControl } from "./volume-control";

const VARIANTS: { value: VolumeVariant; label: string }[] = [
  { label: "Tilt", value: "tilt" },
  { label: "Cannon", value: "cannon" },
  { label: "Curling", value: "curling" },
  { label: "Dots", value: "dots" },
  { label: "+/− game", value: "plus-minus" },
];

const isVariant = (value: string): value is VolumeVariant =>
  VARIANTS.some((variant) => variant.value === value);

const Preview = () => {
  const [variant, setVariant] = useState<VolumeVariant>("tilt");

  return (
    <div className="relative h-screen w-full bg-neutral-950">
      {/* Bottom-left, out of the way: the HUD hangs off the top-right corner. */}
      <span className="absolute bottom-4 left-4 z-10 flex items-center rounded-full border border-white/10 bg-neutral-900">
        <select
          aria-label="Variation"
          value={variant}
          onChange={(event) => {
            if (isVariant(event.target.value)) {
              setVariant(event.target.value);
            }
          }}
          className="appearance-none bg-transparent py-1.5 pr-7 pl-3 text-xs text-neutral-400 outline-none"
        >
          {VARIANTS.map((option) => (
            <option key={option.value} value={option.value}>
              {option.label}
            </option>
          ))}
        </select>
        <ChevronDownIcon className="pointer-events-none absolute right-2.5 size-3.5 text-neutral-500" />
      </span>

      <VolumeControl variant={variant} />
    </div>
  );
};

export default Preview;
```

### `/tilt-track.tsx`

```tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { animate, motion, useMotionValue, useReducedMotion, useTransform } from "motion/react";

import type { MotionValue } from "motion/react";

import { HUD_INNER_WIDTH } from "./macos-chrome";
import {
  arrowDelta,
  clamp,
  clampVolume,
  DETENT_STEP,
  snapVolume,
  VOLUME_MAX,
  VOLUME_MIN,
} from "./volume-scale";

const MARBLE_SIZE = 22;
const GROOVE_HEIGHT = 34;
const TRAVEL = HUD_INNER_WIDTH - MARBLE_SIZE;

/** Degrees of tilt at the ends of the drag. Past this the marble is uncatchable
 * and the joke stops being funny — and the panel starts sweeping through the
 * readout above it (see the clearance the track reserves for its swing). */
const MAX_TILT = 15;
/** Pointer pixels per degree — how heavy the panel feels to lean on. */
const PX_PER_DEGREE = 7;

/** Acceleration (px/s²) the marble would feel if the panel were stood on its end. */
const GRAVITY = 1500;
/** Exponential speed decay per second. Ice would be unplayable; this is more like wood. */
const FRICTION = 2.3;
/** Fraction of speed kept off a wall. The ends are rubber, not a trampoline. */
const RESTITUTION = 0.35;

/** A detent can only catch a marble that's barely moving... */
const CATCH_SPEED = 46;
/** ...and only if it's basically on top of it. */
const CATCH_RADIUS = 7;
/** ...and it lets go the moment the panel leans hard enough to overcome it. This
 * is the whole game: level the panel to let a detent bite, lean to break out. */
const DETENT_GRIP = 300;

const LEVEL_SPRING = { damping: 18, stiffness: 220, type: "spring" } as const;
const SETTLE_SPRING = { damping: 30, stiffness: 400, type: "spring" } as const;

const volumeToX = (volume: number) => (clampVolume(volume) / VOLUME_MAX) * TRAVEL;
const xToVolume = (x: number) => clamp((x / TRAVEL) * VOLUME_MAX, VOLUME_MIN, VOLUME_MAX);
const detentX = (volume: number) => volumeToX(snapVolume(volume));

/** A notch every five points. They're the only places the marble can rest. */
const Detents = () => (
  <>
    {Array.from({ length: VOLUME_MAX / DETENT_STEP + 1 }, (_, index) => {
      const value = index * DETENT_STEP;
      const major = value % 25 === 0;
      return (
        <span
          key={value}
          aria-hidden
          className={`absolute top-1/2 w-px -translate-y-1/2 ${
            major ? "h-4 bg-white/25" : "h-2 bg-white/10"
          }`}
          style={{ left: volumeToX(value) + MARBLE_SIZE / 2 + (GROOVE_HEIGHT - MARBLE_SIZE) / 2 }}
        />
      );
    })}
  </>
);

interface TiltTrackProps {
  volume: MotionValue<number>;
}

/**
 * The panel is a spirit level and the volume is a marble in the groove.
 *
 * Lean on it and gravity does the rest: the marble rolls, gathers speed, and
 * rattles off the ends. Detents every five points will catch it — but only if
 * it's crawling, and only while the panel is near enough to level that the
 * detent can hold it. So you don't set the volume, you *land* it: lean to get
 * moving, then flatten out at exactly the right moment and pray it sticks.
 */
export const TiltTrack = ({ volume }: TiltTrackProps) => {
  const reduceMotion = useReducedMotion();
  const [tilting, setTilting] = useState(false);

  const panelRef = useRef<HTMLDivElement>(null);

  /** Panel lean, in degrees. Positive = right side down. */
  const tilt = useMotionValue(0);
  /** Marble offset along the groove, in px. */
  const x = useMotionValue(volumeToX(volume.get()));

  const velocity = useRef(0);
  const frame = useRef(0);
  const grabX = useRef(0);
  const grabTilt = useRef(0);
  /** The detent the marble is currently sitting in, or null if it's loose. */
  const seated = useRef<number | null>(snapVolume(volume.get()));

  // The marble's position IS the volume — no commit step, no confirmation. The
  // menu bar's speaker icon follows it the entire way down the groove.
  useEffect(() => {
    const unsubscribe = x.on("change", (next) => volume.set(xToVolume(next)));
    return unsubscribe;
  }, [x, volume]);

  // A rolling marble moves the volume every frame. Reporting that through state
  // would re-render the whole panel — detents and all — for each one, so the
  // slider's value is written straight to the attribute instead.
  useEffect(() => {
    const report = (value: number) =>
      panelRef.current?.setAttribute("aria-valuenow", String(Math.round(value)));
    report(volume.get());
    return volume.on("change", report);
  }, [volume]);

  useEffect(() => () => cancelAnimationFrame(frame.current), []);

  const run = () => {
    cancelAnimationFrame(frame.current);
    let last = performance.now();

    const step = (now: number) => {
      // Clamped so a backgrounded tab doesn't resume with one enormous step and
      // fire the marble straight through a wall.
      const dt = Math.min((now - last) / 1000, 1 / 30);
      last = now;

      const radians = (tilt.get() * Math.PI) / 180;
      const accel = GRAVITY * Math.sin(radians);
      let v = velocity.current;
      let next = x.get();

      const seatedAt = seated.current;
      if (seatedAt !== null) {
        // Sitting in a detent: nothing happens until the lean beats the grip.
        if (Math.abs(accel) < DETENT_GRIP) {
          velocity.current = 0;
          frame.current = requestAnimationFrame(step);
          return;
        }
        seated.current = null;
      }

      v += accel * dt;
      v *= Math.exp(-FRICTION * dt);
      next += v * dt;

      if (next < 0) {
        next = -next;
        v = -v * RESTITUTION;
      } else if (next > TRAVEL) {
        next = 2 * TRAVEL - next;
        v = -v * RESTITUTION;
      }

      // A detent bites only a slow marble sitting right on top of it, and only
      // while the panel is flat enough to hold it there.
      const candidate = detentX(xToVolume(next));
      if (
        Math.abs(v) < CATCH_SPEED &&
        Math.abs(candidate - next) < CATCH_RADIUS &&
        Math.abs(accel) < DETENT_GRIP
      ) {
        x.set(candidate);
        velocity.current = 0;
        seated.current = snapVolume(xToVolume(candidate));
        frame.current = requestAnimationFrame(step);
        return;
      }

      x.set(next);
      velocity.current = v;
      frame.current = requestAnimationFrame(step);
    };

    frame.current = requestAnimationFrame(step);
  };

  const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
    if (reduceMotion) {
      return;
    }
    event.currentTarget.setPointerCapture(event.pointerId);
    grabX.current = event.clientX;
    grabTilt.current = tilt.get();
    setTilting(true);
    run();
  };

  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
    if (!tilting) {
      return;
    }
    const delta = (event.clientX - grabX.current) / PX_PER_DEGREE;
    tilt.set(clamp(grabTilt.current + delta, -MAX_TILT, MAX_TILT));
  };

  const handlePointerUp = () => {
    if (!tilting) {
      return;
    }
    setTilting(false);
    // Hands off: the panel finds level again, and whatever the marble was doing,
    // it now has to do it on a flat surface. Usually that means it stops.
    void animate(tilt, 0, LEVEL_SPRING);
  };

  const settleTo = (next: number) => {
    cancelAnimationFrame(frame.current);
    velocity.current = 0;
    seated.current = snapVolume(next);
    void animate(x, detentX(next), SETTLE_SPRING);
  };

  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    const delta = arrowDelta(event.key);
    if (delta === 0) {
      return;
    }
    // Keyboard gets the boring volume control. The joke isn't worth locking anyone out.
    event.preventDefault();
    settleTo(clampVolume(snapVolume(volume.get()) + delta));
  };

  const bubbleX = useTransform(tilt, [-MAX_TILT, MAX_TILT], [-22, 22]);
  const marbleSpin = useTransform(x, (value) => (value / (Math.PI * MARBLE_SIZE)) * 360);

  return (
    <div>
      {/* The panel swings, so it needs room to swing in: a level bar this wide sweeps
          a box far taller than itself once it leans, and without the clearance it would
          scythe straight through the readout above it. */}
      <motion.div
        ref={panelRef}
        // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role, jsx-a11y/role-has-required-aria-props -- a physics toy, not a range input; aria-valuenow is written straight to the DOM every frame (see the effect above)
        role="slider"
        tabIndex={0}
        aria-label="Volume"
        aria-valuemin={VOLUME_MIN}
        aria-valuemax={VOLUME_MAX}
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        onPointerUp={handlePointerUp}
        onPointerCancel={handlePointerUp}
        onKeyDown={handleKeyDown}
        style={{ rotate: reduceMotion ? 0 : tilt, touchAction: "none" }}
        className={`relative my-[62px] rounded-2xl bg-neutral-900/70 px-3 py-3 outline-none focus-visible:ring-2 focus-visible:ring-white/70 ${
          tilting ? "cursor-grabbing" : "cursor-grab"
        }`}
      >
        <div
          className="relative overflow-hidden rounded-full bg-neutral-950/80 shadow-[inset_0_2px_6px_rgba(0,0,0,0.7)]"
          style={{ height: GROOVE_HEIGHT }}
        >
          <Detents />

          <motion.span
            aria-hidden
            className="absolute top-1/2 left-0 rounded-full bg-gradient-to-b from-white to-neutral-400 shadow-[0_2px_5px_rgba(0,0,0,0.6)]"
            style={{
              height: MARBLE_SIZE,
              marginLeft: (GROOVE_HEIGHT - MARBLE_SIZE) / 2,
              marginTop: -MARBLE_SIZE / 2,
              rotate: reduceMotion ? 0 : marbleSpin,
              width: MARBLE_SIZE,
              x,
            }}
          >
            {/* One off-centre highlight, so the roll is visible. A featureless
                ball sliding down a groove reads as a slider handle. */}
            <span className="absolute top-1 left-[6px] size-1.5 rounded-full bg-white/90" />
          </motion.span>
        </div>
      </motion.div>

      {/* The bubble level: the only feedback telling you how hard you're leaning. */}
      <div
        aria-hidden
        className="mx-auto mt-3 flex h-5 w-24 items-center justify-center rounded-full border border-white/10 bg-neutral-900/70"
      >
        <span className="absolute h-5 w-5 rounded-full border-x border-white/15" />
        <motion.span
          className="size-3 rounded-full bg-emerald-400/80 shadow-[0_0_8px_rgba(52,211,153,0.6)]"
          style={{ x: reduceMotion ? 0 : bubbleX }}
        />
      </div>
    </div>
  );
};
```

### `/volume-control.tsx`

```tsx
"use client";

import { useState } from "react";
import { AnimatePresence, motion, useMotionValue, useTransform } from "motion/react";

import type { MotionValue } from "motion/react";
import type { ReactNode } from "react";

import { CannonTrack } from "./cannon-track";
import { CurlingTrack } from "./curling-track";
import { DotsTrack } from "./dots-track";
import { MacosChrome, VolumeHud } from "./macos-chrome";
import { PlusMinusTrack } from "./plus-minus-track";
import { TiltTrack } from "./tilt-track";
import { DEFAULT_VOLUME } from "./volume-scale";

export type VolumeVariant = "tilt" | "cannon" | "curling" | "dots" | "plus-minus";

interface VolumeControlProps {
  variant?: VolumeVariant;
}

const HUD_POP = { damping: 30, stiffness: 380, type: "spring" } as const;

const HINTS = {
  cannon: "Pull back, let go, and hope.",
  curling: "Throw the stone. Sweep to keep it going.",
  dots: "Draw the number you want. It reads dots, nothing else.",
  "plus-minus": "Hit + for louder. Good luck catching it.",
  tilt: "Tilt the panel. Roll the marble. Don't overshoot.",
} satisfies Record<VolumeVariant, string>;

/** Every control takes the volume and nothing else, so the shell can pick one by
 * name. Keyed by variant rather than branched on it: adding a sixth control makes
 * this table and HINTS fail to compile until both know about it. */
const TRACKS = {
  cannon: CannonTrack,
  curling: CurlingTrack,
  dots: DotsTrack,
  "plus-minus": PlusMinusTrack,
  tilt: TiltTrack,
} satisfies Record<VolumeVariant, (props: { volume: MotionValue<number> }) => ReactNode>;

/** Each bar is named, so the set can be walked without leaning on its index. */
const BARS = [
  { height: 0.45, id: "a" },
  { height: 0.8, id: "b" },
  { height: 0.35, id: "c" },
  { height: 1, id: "d" },
  { height: 0.6, id: "e" },
  { height: 0.9, id: "f" },
  { height: 0.5, id: "g" },
  { height: 0.75, id: "h" },
  { height: 0.4, id: "i" },
  { height: 0.85, id: "j" },
  { height: 0.55, id: "k" },
  { height: 0.7, id: "l" },
];

const LevelBar = ({ height, volume }: { height: number; volume: MotionValue<number> }) => {
  const scaleY = useTransform(volume, [0, 100], [0.06, height]);
  return (
    <motion.span
      className="h-full w-2.5 origin-bottom rounded-full bg-gradient-to-t from-indigo-400/60 to-rose-300/60"
      style={{ scaleY }}
    />
  );
};

/** The only honest feedback in the whole component: bars that get taller when the
 * volume does. They're a readout, not a control — you cannot drag them either. */
const Levels = ({ volume }: { volume: MotionValue<number> }) => (
  <div className="flex h-12 items-end gap-1.5">
    {BARS.map((bar) => (
      <LevelBar key={bar.id} height={bar.height} volume={volume} />
    ))}
  </div>
);

/**
 * The app you were trying to turn down, sitting behind everything, bleeding off the
 * bottom-left of the frame. Pure scenery with one exception: its levels ride the
 * volume, so whatever the control up there finally decides, you see it land here.
 */
const NowPlayingWindow = ({ volume }: { volume: MotionValue<number> }) => (
  <div
    aria-hidden
    className="absolute top-1/2 -left-20 w-[640px] -translate-y-1/2 rounded-3xl border border-white/[0.07] bg-neutral-900/60 p-7 shadow-2xl shadow-black/50 backdrop-blur-sm"
  >
    <div className="mb-6 flex gap-2">
      <span className="size-3 rounded-full bg-neutral-700" />
      <span className="size-3 rounded-full bg-neutral-700" />
      <span className="size-3 rounded-full bg-neutral-700" />
    </div>

    <div className="flex items-center gap-6">
      <div className="size-32 shrink-0 rounded-2xl bg-gradient-to-br from-indigo-500/40 to-rose-500/30" />

      <div className="min-w-0 flex-1">
        <p className="truncate text-[19px] font-medium text-neutral-200">Everything Louder</p>
        <p className="mb-5 truncate text-[15px] text-neutral-500">
          Motörhead — No Sleep &apos;til Hammersmith
        </p>
        <Levels volume={volume} />
      </div>
    </div>
  </div>
);

/**
 * Five volume controls, none of which will let you simply set the volume.
 *
 * The staging is the same trick as the effort picker: we're zoomed into the top
 * corner of somebody's desktop, the menu bar bleeds off the frame, and the HUD
 * hangs off the speaker icon like it would in the real thing. The shell owns the
 * volume as a motion value so the menu bar can follow a rolling marble frame by
 * frame without re-rendering React — the controls write to it, the chrome reads
 * it, and neither knows anything about the other.
 */
export const VolumeControl = ({ variant = "tilt" }: VolumeControlProps) => {
  const volume = useMotionValue(DEFAULT_VOLUME);
  const [open, setOpen] = useState(true);

  // Swapping controls would otherwise hand the next one a volume the last one
  // earned, which reads as a bug rather than a joke. Everyone starts at 35.
  const [renderedVariant, setRenderedVariant] = useState(variant);
  if (renderedVariant !== variant) {
    setRenderedVariant(variant);
    volume.set(DEFAULT_VOLUME);
  }

  const Track = TRACKS[variant];

  const hud = (
    <AnimatePresence>
      {open && (
        <motion.div
          key={variant}
          initial={{ opacity: 0, scale: 0.96, y: -6 }}
          animate={{ opacity: 1, scale: 1, y: 0 }}
          exit={{ opacity: 0, scale: 0.96, y: -4 }}
          transition={HUD_POP}
          style={{ transformOrigin: "top right" }}
          className="absolute top-full right-0 z-10 mt-2.5"
        >
          <VolumeHud volume={volume} title="Output level" hint={HINTS[variant]}>
            <Track volume={volume} />
          </VolumeHud>
        </motion.div>
      )}
    </AnimatePresence>
  );

  return (
    <div className="relative flex h-full w-full items-start justify-end overflow-hidden bg-neutral-950 text-neutral-100">
      {/* Somebody else's desktop, cropped: the wallpaper, the menu bar and the window
          behind it all run wider than the frame. The zoom is intrinsic sizing, never a
          transform — scaling this subtree would put the controls' pointer deltas (screen
          px) out of step with their physics (local px), and the marble would outrun your
          hand. */}
      <div
        aria-hidden
        className="absolute inset-0 bg-gradient-to-br from-indigo-950/50 via-neutral-950 to-neutral-950"
      />

      <NowPlayingWindow volume={volume} />

      <div className="relative w-[calc(100%+220px)] shrink-0 px-6 pt-4">
        <MacosChrome
          volume={volume}
          open={open}
          onToggle={() => setOpen((was) => !was)}
          hud={hud}
        />
      </div>
    </div>
  );
};
```

### `/volume-scale.ts`

```ts
/**
 * The volume domain, shared by the HUD and all five controls.
 *
 * Volume is a number 0–100 and nothing else. Each control owns its own geometry
 * — a groove, a board, a sheet of ice — and converts to and from this domain at
 * its edges, so no control ever has to know what another one's pixels mean.
 */

export const VOLUME_MIN = 0;
export const VOLUME_MAX = 100;
export const DEFAULT_VOLUME = 35;

/** Volume lands on multiples of this. Nothing here is precise enough to deserve
 * finer resolution, and a round number reads better in the readout. */
export const DETENT_STEP = 5;

export const clamp = (value: number, min: number, max: number) =>
  Math.min(Math.max(value, min), max);

export const clampVolume = (value: number) => clamp(value, VOLUME_MIN, VOLUME_MAX);

export const snapVolume = (value: number) =>
  clampVolume(Math.round(value / DETENT_STEP) * DETENT_STEP);

export type SpeakerTier = "muted" | "low" | "mid" | "high";

/** Which speaker glyph the menu bar wears. */
export const speakerTier = (volume: number): SpeakerTier => {
  if (volume <= 0) {
    return "muted";
  }
  if (volume < 34) {
    return "low";
  }
  if (volume < 67) {
    return "mid";
  }
  return "high";
};

/** One detent per arrow key: right/up louder, left/down quieter, anything else nothing. */
export const arrowDelta = (key: string) => {
  if (key === "ArrowRight" || key === "ArrowUp") {
    return DETENT_STEP;
  }
  if (key === "ArrowLeft" || key === "ArrowDown") {
    return -DETENT_STEP;
  }
  return 0;
};
```
