uicapsule
blockvolume-controlsource: ok · hit
Volume Control
npx shadcn add "https://uicapsule.com/r/volume-control.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumiconslucide-react
animationmotion
Files9 · source cached 10/8/2026, 4:05:21 PM
/cannon-track.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
"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
"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
"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
"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
"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
"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
"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 '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
/**
* 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;
};