# Tumbler — Animated Input for React

> 3D drum picker — rows ride a perspective cylinder, flick to spin with momentum that lands on a detent, the centred row lit between accent rule lines. Drag, touch, wheel notches, tap-a-row and full keyboard (arrows, PageUp/Down, Home/End, type-ahead). Listbox or spinbutton semantics; TumblerGroup shares one band across drums for time and date pickers. Flat list under reduced motion.

- Page: https://crucibleui.com/components/inputs/tumbler
- Category: [inputs](https://crucibleui.com/components/inputs)
- Tier: free (full source)
- Engine: motion
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/tumbler
```

Registry JSON: https://crucibleui.com/r/tumbler.json

Import from `@/components/crucible/tumbler`.

## Props (`TumblerGroupProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label?` | `string` | — | Accessible name for the whole picker, e.g. "Time". |
| `visibleRows?` | `number` | `5` | Rows visible on every drum. |
| `rowHeight?` | `number` | `40` | Row height in px for every drum. |
| `accentColor?` | `string` | `"#a5b4fc"` | Accent for the shared band's rule lines and focus rings. |
| `children?` | `React.ReactNode` | — | Drums and any separators (e.g. a ":" span). |

Also accepts: `React.HTMLAttributes<HTMLDivElement>`.

## Usage example

```tsx
"use client";

import * as React from "react";

import { Tumbler, TumblerGroup } from "@/components/crucible/tumbler";

const MERIDIEM = ["AM", "PM"];
const PLANS = ["Starter", "Basic", "Plus", "Pro", "Team", "Business", "Enterprise"];
const pad = (n: number) => String(n).padStart(2, "0");

const LABEL = "text-[10px] font-medium uppercase tracking-[0.2em] text-neutral-600";

export default function TumblerDemo() {
  const [hour, setHour] = React.useState(9);
  const [minute, setMinute] = React.useState(30);
  const [meridiem, setMeridiem] = React.useState("AM");
  const [plan, setPlan] = React.useState("Pro");

  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full items-center justify-center overflow-hidden rounded-xl bg-neutral-950">
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(255,255,255,0.035),transparent_70%)]"
      />
      <div className="relative z-10 flex w-full flex-col items-center gap-7 px-6 py-8">
        <div className="flex flex-wrap items-start justify-center gap-x-12 gap-y-8">
          <div className="flex flex-col items-center gap-3">
            <span id="tumbler-time-label" className={LABEL}>
              Time
            </span>
            <TumblerGroup aria-labelledby="tumbler-time-label">
              <Tumbler
                mode="number"
                min={1}
                max={12}
                loop
                label="Hour"
                value={hour}
                onValueChange={setHour}
              />
              <span aria-hidden className="pb-0.5 text-[17px] font-medium text-neutral-500">
                :
              </span>
              <Tumbler
                mode="number"
                min={0}
                max={59}
                loop
                label="Minute"
                formatValue={pad}
                value={minute}
                onValueChange={setMinute}
                introDelay={0.08}
              />
              <Tumbler
                options={MERIDIEM}
                label="AM or PM"
                value={meridiem}
                onValueChange={setMeridiem}
                introDelay={0.16}
              />
            </TumblerGroup>
          </div>

          <div className="flex flex-col items-center gap-3">
            <span id="tumbler-plan-label" className={LABEL}>
              Plan
            </span>
            <Tumbler
              options={PLANS}
              aria-labelledby="tumbler-plan-label"
              value={plan}
              onValueChange={setPlan}
              className="min-w-36"
              introDelay={0.24}
            />
          </div>
        </div>

        <p className="text-center text-sm text-neutral-500">
          Flick, scroll or use the arrow keys.
        </p>

        <span className="h-5 text-xs text-neutral-600">
          <span className="text-neutral-300">
            {hour}:{pad(minute)} {meridiem}
          </span>{" "}
          on <span className="text-neutral-300">{plan}</span>
        </span>
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.

## Source (`tumbler.tsx`)

```tsx
"use client";

import { animate, motion, useMotionValue } from "motion/react";
import * as React from "react";

import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/use-reduced-motion";

/* -------------------------------------------------------------------------- */
/*                                    Types                                   */
/* -------------------------------------------------------------------------- */

/** A list-mode option: a plain string, or an object for custom labels. */
export type TumblerOption =
  | string
  | {
      /** Unique, stable value reported through `onValueChange`. */
      value: string;
      /** Visible label. @default value */
      label?: React.ReactNode;
      /** Plain text used for type-ahead matching when `label` is not a string. @default value */
      textValue?: string;
      /** Skip this row — the drum never settles on it. */
      disabled?: boolean;
    };

/** Imperative handle — see `TumblerProps.ref`. */
export interface TumblerHandle {
  /** Spin to a value (an option value in list mode, a number in number mode). */
  spinTo: (value: string | number) => void;
  /** Spin by `delta` rows (positive = next row). */
  step: (delta: number) => void;
  /** Re-run the intro spin-in. */
  replay: () => void;
  /** Move keyboard focus onto the drum. */
  focus: () => void;
}

interface TumblerBaseProps
  extends Omit<
    React.HTMLAttributes<HTMLDivElement>,
    "onChange" | "defaultValue" | "role" | "children"
  > {
  /** Wrap around past the last row back to the first. @default false */
  loop?: boolean;
  /** Rows visible on the drum (odd numbers center best). @default 5 — inside a `TumblerGroup` the group's value wins. */
  visibleRows?: number;
  /** Height of one row in px. @default 40 — inside a `TumblerGroup` the group's value wins. */
  rowHeight?: number;
  /** Accessible name for the drum. Or pass `aria-labelledby`. @default "Choose a value" */
  label?: string;
  /** The single accent: rule lines around the lit band + focus ring. @default "#a5b4fc" (periwinkle) — inside a group the group's accent wins. */
  accentColor?: string;
  /** Disable the drum (no pointer, wheel or keyboard input). @default false */
  disabled?: boolean;
  /** Seconds to wait before the intro spin-in — stagger drums in a group. @default 0 */
  introDelay?: number;
  /** Submit the value with a form under this field name (renders a hidden input). */
  name?: string;
  /** Imperative handle: `spinTo`, `step`, `replay`, `focus`. */
  ref?: React.Ref<TumblerHandle>;
}

interface TumblerListModeProps {
  /** `"list"` = a listbox of `options`; `"number"` = a spinbutton over `min`–`max`. @default "list" when `options` is set, else "number" */
  mode?: "list";
  /** The rows, top to bottom. */
  options: readonly TumblerOption[];
  /** Controlled selected value. */
  value?: string;
  /** Initial value when uncontrolled. @default first enabled option */
  defaultValue?: string;
  /** Fires when the drum commits to a new row (on release, notch or key — not mid-drag). */
  onValueChange?: (value: string) => void;
  min?: never;
  max?: never;
  step?: never;
  formatValue?: never;
}

interface TumblerNumberModeProps {
  /** `"list"` = a listbox of `options`; `"number"` = a spinbutton over `min`–`max`. @default "list" when `options` is set, else "number" */
  mode?: "number";
  options?: never;
  /** Lowest value (top row). @default 0 */
  min?: number;
  /** Highest value (bottom row). @default 59 */
  max?: number;
  /** Increment between rows. @default 1 */
  step?: number;
  /** Row label formatter, e.g. `(n) => String(n).padStart(2, "0")`. @default String */
  formatValue?: (value: number) => string;
  /** Controlled value. */
  value?: number;
  /** Initial value when uncontrolled. @default min */
  defaultValue?: number;
  /** Fires when the drum commits to a new row (on release, notch or key — not mid-drag). */
  onValueChange?: (value: number) => void;
}

export type TumblerProps = TumblerBaseProps &
  (TumblerListModeProps | TumblerNumberModeProps);

export interface TumblerGroupProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Accessible name for the whole picker, e.g. "Time". */
  label?: string;
  /** Rows visible on every drum. @default 5 */
  visibleRows?: number;
  /** Row height in px for every drum. @default 40 */
  rowHeight?: number;
  /** Accent for the shared band's rule lines and focus rings. @default "#a5b4fc" */
  accentColor?: string;
  /** Drums and any separators (e.g. a ":" span). */
  children?: React.ReactNode;
}

/* -------------------------------------------------------------------------- */
/*                                  Geometry                                  */
/* -------------------------------------------------------------------------- */

const DEFAULT_ACCENT = "#a5b4fc";
/** Angle of the drum surface at the outer edge of the visible window. */
const EDGE_ANGLE = 75;
/** Seconds of flick momentum projected forward on release. */
const FLICK_TAU = 0.2;
const DEG = Math.PI / 180;

interface Geometry {
  rowHeight: number;
  half: number;
  /** Degrees between neighbouring rows. */
  theta: number;
  /** Drum radius in px. */
  radius: number;
  height: number;
  perspective: number;
  /** Rows either side of center that get rendered. */
  reach: number;
}

function geometry(visibleRows: number, rowHeight: number, flat: boolean): Geometry {
  const rows = Math.max(1, Math.round(visibleRows));
  const half = (rows - 1) / 2;
  if (flat) {
    return {
      rowHeight,
      half,
      theta: 0,
      radius: 0,
      height: rows * rowHeight,
      perspective: 0,
      reach: Math.ceil(half) + 1,
    };
  }
  const theta = Math.min(60, EDGE_ANGLE / (half + 0.5));
  // Chord between neighbours ≈ rowHeight, so the centre row is true size.
  const radius = rowHeight / (2 * Math.tan((theta / 2) * DEG));
  const height = Math.ceil(2 * radius * Math.sin(Math.min(88, (half + 0.5) * theta) * DEG)) + 8;
  return {
    rowHeight,
    half,
    theta,
    radius,
    height,
    perspective: Math.max(260, radius * 3.2),
    reach: Math.min(Math.ceil(90 / theta), Math.ceil(half) + 3),
  };
}

function rowStyle(offset: number, g: Geometry, flat: boolean): React.CSSProperties {
  const dist = Math.abs(offset);
  if (flat) {
    return {
      transform: `translate3d(0, ${offset * g.rowHeight}px, 0)`,
      opacity: dist > g.half + 0.5 ? 0 : Math.max(0.25, 1 - dist * 0.22),
      visibility: dist > g.half + 1 ? "hidden" : "visible",
    };
  }
  const angle = -offset * g.theta;
  const facing = Math.cos(angle * DEG);
  const visible = Math.abs(angle) < 89;
  return {
    transform: `translateZ(${-g.radius}px) rotateX(${angle}deg) translateZ(${g.radius}px)`,
    opacity: visible ? Math.pow(Math.max(0, facing), 1.15) : 0,
    filter: dist > 0.6 ? `blur(${Math.min(1.2, (dist - 0.6) * 0.4).toFixed(2)}px)` : undefined,
    visibility: visible ? "visible" : "hidden",
  };
}

const mod = (k: number, n: number) => ((k % n) + n) % n;

/* -------------------------------------------------------------------------- */
/*                                Group context                               */
/* -------------------------------------------------------------------------- */

interface GroupContextValue {
  visibleRows: number;
  rowHeight: number;
  accentColor: string;
}

const TumblerGroupContext = React.createContext<GroupContextValue | null>(null);

/* -------------------------------------------------------------------------- */
/*                                   Tumbler                                  */
/* -------------------------------------------------------------------------- */

interface NormalizedRow {
  key: string;
  label: React.ReactNode;
  text: string;
  disabled: boolean;
  /** List value (string) or number-mode value. */
  value: string | number;
}

/**
 * Tumbler — a 3D drum picker. Rows ride a cylinder (each row rotated about X by
 * its distance from centre and pushed out by the drum radius, under
 * perspective), falling away with opacity and a whisper of blur. Flick to spin
 * with momentum that lands on a detent; the centred row is lit between two
 * accent rule lines. Mouse drag, touch, wheel (one notch per row), click a
 * row, and full keyboard: Arrow keys, PageUp/PageDown (±5), Home/End and
 * type-ahead.
 *
 * Accessibility: list mode is a `role="listbox"` with `aria-activedescendant`
 * pointing at the selected `role="option"`; number mode is a
 * `role="spinbutton"` with `aria-valuenow/min/max/text` (ArrowUp increases).
 * Reduced motion: no rotation or inertia — a flat list with the selected row
 * highlighted, and every change snaps.
 *
 * Use `TumblerGroup` to stand several drums side by side under one shared
 * highlight band (time, dates).
 */
export function Tumbler(props: TumblerProps) {
  const {
    mode: modeProp,
    options,
    min: minProp,
    max: maxProp,
    step: stepProp,
    formatValue,
    value,
    defaultValue,
    onValueChange,
    loop = false,
    visibleRows: visibleRowsProp = 5,
    rowHeight: rowHeightProp = 40,
    label = "Choose a value",
    accentColor: accentProp = DEFAULT_ACCENT,
    disabled = false,
    introDelay = 0,
    name,
    ref,
    className,
    style,
    "aria-labelledby": ariaLabelledby,
    ...rest
  } = props;

  const group = React.useContext(TumblerGroupContext);
  const visibleRows = group?.visibleRows ?? visibleRowsProp;
  const rowHeight = group?.rowHeight ?? rowHeightProp;
  const accentColor = group?.accentColor ?? accentProp;

  const reducedMotion = useReducedMotion();
  const flat = reducedMotion;
  const g = React.useMemo(
    () => geometry(visibleRows, rowHeight, flat),
    [visibleRows, rowHeight, flat]
  );

  const mode: "list" | "number" = modeProp ?? (options ? "list" : "number");
  const isNumber = mode === "number";
  const min = minProp ?? 0;
  const max = maxProp ?? 59;
  const step = stepProp && stepProp > 0 ? stepProp : 1;

  /* ------------------------------ rows / values ----------------------------- */

  const decimals = (String(step).split(".")[1] ?? "").length;
  const count = isNumber
    ? Math.max(1, Math.floor((max - min) / step + 1e-9) + 1)
    : Math.max(1, options?.length ?? 0);


  const listRows = React.useMemo<NormalizedRow[] | null>(() => {
    if (isNumber) return null;
    const list = options ?? [];
    if (list.length === 0) return [{ key: "", label: "", text: "", disabled: true, value: "" }];
    return list.map((o) =>
      typeof o === "string"
        ? { key: o, label: o, text: o, disabled: false, value: o }
        : {
            key: o.value,
            label: o.label ?? o.value,
            text: o.textValue ?? (typeof o.label === "string" ? o.label : o.value),
            disabled: !!o.disabled,
            value: o.value,
          }
    );
  }, [isNumber, options]);

  const rowAt = React.useCallback(
    (index: number): NormalizedRow => {
      if (listRows) return listRows[index] ?? listRows[0];
      const v = Number((min + index * step).toFixed(decimals));
      const text = formatValue ? formatValue(v) : String(v);
      return { key: String(v), label: text, text, disabled: false, value: v };
    },
    [listRows, min, step, decimals, formatValue]
  );

  const indexOf = React.useCallback(
    (v: string | number | undefined): number => {
      if (v === undefined) return -1;
      if (listRows) return listRows.findIndex((r) => r.value === v);
      const n = typeof v === "number" ? v : Number(v);
      if (!Number.isFinite(n)) return -1;
      return Math.min(count - 1, Math.max(0, Math.round((n - min) / step)));
    },
    [listRows, count, min, step]
  );

  const firstEnabled = listRows ? Math.max(0, listRows.findIndex((r) => !r.disabled)) : 0;

  /* ---------------------------------- state --------------------------------- */

  const isControlled = value !== undefined;
  const [internalIndex, setInternalIndex] = React.useState(() => {
    const i = indexOf(defaultValue);
    return i >= 0 ? i : firstEnabled;
  });
  const controlledIndex = isControlled ? indexOf(value) : -1;
  const selectedIndex = isControlled
    ? controlledIndex >= 0
      ? controlledIndex
      : firstEnabled
    : Math.min(internalIndex, count - 1);

  /** Continuous drum position in rows (unbounded when looping). */
  const pos = useMotionValue(selectedIndex);
  /** Virtual row the drum is heading to / resting on. */
  const targetKRef = React.useRef(selectedIndex);
  const [targetK, setTargetK] = React.useState(selectedIndex);
  const [centerK, setCenterK] = React.useState(selectedIndex);
  const intro = useMotionValue(1);
  const animRef = React.useRef<{ stop: () => void } | null>(null);
  const introRanRef = React.useRef(false);

  const rootRef = React.useRef<HTMLDivElement>(null);

  const latest = React.useRef({ g, flat, count, loop, selectedIndex, isControlled, onValueChange });
  React.useLayoutEffect(() => {
    latest.current = { g, flat, count, loop, selectedIndex, isControlled, onValueChange };
  });

  /* ------------------------------ DOM painting ------------------------------ */

  const paint = React.useCallback((p: number) => {
    const { g: geo, flat: isFlat } = latest.current;
    rootRef.current?.querySelectorAll<HTMLElement>("[data-tumbler-row]").forEach((el) => {
      const s = rowStyle(Number(el.dataset.tumblerRow) - p, geo, isFlat);
      el.style.transform = s.transform as string;
      el.style.opacity = String(s.opacity);
      el.style.filter = (s.filter as string) ?? "";
      el.style.visibility = s.visibility as string;
    });
  }, []);

  React.useEffect(() => {
    paint(pos.get());
    return pos.on("change", (p) => {
      paint(p);
      const c = Math.round(p);
      setCenterK((prev) => (prev === c ? prev : c));
    });
  }, [pos, paint]);

  // Geometry flips (reduced motion, row height) repaint immediately.
  React.useLayoutEffect(() => {
    paint(pos.get());
  }, [g, flat, paint, pos]);

  /* ------------------------------- navigation ------------------------------- */

  const resolveEnabled = React.useCallback(
    (k: number, dir: number): number => {
      if (!listRows) return k;
      const n = listRows.length;
      const d = dir >= 0 ? 1 : -1;
      for (let i = 0; i < n; i++) {
        for (const cand of i === 0 ? [k] : [k + d * i, k - d * i]) {
          if (!loop && (cand < 0 || cand > n - 1)) continue;
          if (!listRows[mod(cand, n)]?.disabled) return cand;
        }
      }
      return k;
    },
    [listRows, loop]
  );

  const runTo = React.useCallback(
    (k: number, velocity = 0) => {
      animRef.current?.stop();
      if (latest.current.flat) {
        pos.set(k);
        return;
      }
      animRef.current = animate(pos, k, {
        type: "spring",
        stiffness: 210,
        damping: 26,
        mass: 0.9,
        velocity,
        restDelta: 0.001,
      });
    },
    [pos]
  );

  /** Commit to virtual row `k` and spin there. */
  const goTo = React.useCallback(
    (rawK: number, opts: { velocity?: number; dir?: number } = {}) => {
      const { count: n, loop: isLoop } = latest.current;
      let k = Math.round(rawK);
      if (!isLoop) k = Math.min(n - 1, Math.max(0, k));
      k = resolveEnabled(k, opts.dir ?? Math.sign(rawK - targetKRef.current));
      targetKRef.current = k;
      setTargetK(k);
      const index = mod(k, n);
      if (index !== latest.current.selectedIndex) {
        if (!latest.current.isControlled) setInternalIndex(index);
        const cb = latest.current.onValueChange as ((v: string | number) => void) | undefined;
        cb?.(rowAt(index).value);
      }
      runTo(k, opts.velocity ?? 0);
    },
    [resolveEnabled, rowAt, runTo]
  );

  /** Nearest virtual row (to the current position) that shows `index`. */
  const nearestK = React.useCallback(
    (index: number) => {
      const { count: n, loop: isLoop } = latest.current;
      if (!isLoop) return index;
      const p = pos.get();
      return index + Math.round((p - index) / n) * n;
    },
    [pos]
  );

  // External value changes (controlled mode or option-set changes) spin in.
  React.useEffect(() => {
    if (mod(targetKRef.current, count) === selectedIndex) return;
    const k = nearestK(selectedIndex);
    targetKRef.current = k;
    setTargetK(k);
    runTo(k);
  }, [selectedIndex, count, nearestK, runTo]);

  /* ---------------------------------- intro --------------------------------- */

  const playIntro = React.useCallback(() => {
    if (latest.current.flat) return;
    const { count: n, loop: isLoop } = latest.current;
    const k = targetKRef.current;
    const span = Math.min(4, Math.max(2, n - 1));
    const from = isLoop || k - span >= 0 ? k - span : k + span;
    animRef.current?.stop();
    pos.set(from);
    intro.set(0);
    animate(intro, 1, { duration: 0.45, ease: "easeOut", delay: introDelay });
    animRef.current = animate(pos, k, {
      type: "spring",
      stiffness: 90,
      damping: 17,
      mass: 1,
      delay: introDelay,
    });
  }, [pos, intro, introDelay]);

  React.useLayoutEffect(() => {
    const el = rootRef.current;
    if (!el || introRanRef.current) return;
    const fire = () => {
      if (introRanRef.current) return;
      introRanRef.current = true;
      if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
      playIntro();
    };
    const r = el.getBoundingClientRect();
    if (r.bottom > 0 && r.top < window.innerHeight && r.width > 0) {
      fire();
      return;
    }
    const io = new IntersectionObserver(
      (entries) => {
        if (entries.some((e) => e.isIntersecting)) {
          io.disconnect();
          fire();
        }
      },
      { threshold: 0.3 }
    );
    io.observe(el);
    return () => io.disconnect();
  }, [playIntro]);

  // Reduced motion mid-flight: drop any spin and sit on the target.
  React.useEffect(() => {
    if (!flat) return;
    animRef.current?.stop();
    intro.set(1);
    pos.set(targetKRef.current);
  }, [flat, pos, intro]);

  React.useEffect(() => () => animRef.current?.stop(), []);

  /* --------------------------------- handle --------------------------------- */

  React.useImperativeHandle(
    ref,
    () => ({
      spinTo: (v) => {
        const i = indexOf(v);
        if (i >= 0) goTo(nearestK(i));
      },
      step: (delta) => goTo(targetKRef.current + delta, { dir: Math.sign(delta) }),
      replay: playIntro,
      focus: () => rootRef.current?.focus(),
    }),
    [indexOf, goTo, nearestK, playIntro]
  );

  /* --------------------------------- pointer -------------------------------- */

  const drag = React.useRef<{
    id: number;
    startY: number;
    startPos: number;
    moved: boolean;
    samples: { t: number; y: number }[];
  } | null>(null);

  const rubber = (p: number) => {
    const { count: n, loop: isLoop } = latest.current;
    if (isLoop) return p;
    const band = (x: number) => (1 - 1 / (x * 0.6 + 1)) * 1.2;
    if (p < 0) return -band(-p);
    if (p > n - 1) return n - 1 + band(p - (n - 1));
    return p;
  };

  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
    if (disabled || (e.pointerType === "mouse" && e.button !== 0)) return;
    animRef.current?.stop();
    e.currentTarget.setPointerCapture(e.pointerId);
    drag.current = {
      id: e.pointerId,
      startY: e.clientY,
      startPos: pos.get(),
      moved: false,
      samples: [{ t: performance.now(), y: e.clientY }],
    };
  };

  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
    const d = drag.current;
    if (!d || d.id !== e.pointerId) return;
    const dy = e.clientY - d.startY;
    if (Math.abs(dy) > 4) d.moved = true;
    if (!d.moved) return;
    pos.set(rubber(d.startPos - dy / rowHeight));
    const now = performance.now();
    d.samples.push({ t: now, y: e.clientY });
    while (d.samples.length > 2 && now - d.samples[0].t > 100) d.samples.shift();
  };

  const endDrag = (e: React.PointerEvent<HTMLDivElement>, cancelled = false) => {
    const d = drag.current;
    if (!d || d.id !== e.pointerId) return;
    drag.current = null;
    if (!d.moved) {
      if (cancelled) return goTo(targetKRef.current);
      // Tap: spin the tapped row to centre.
      const rect = e.currentTarget.getBoundingClientRect();
      const y = e.clientY - (rect.top + rect.height / 2);
      const { g: geo, flat: isFlat } = latest.current;
      const rows = isFlat
        ? y / geo.rowHeight
        : Math.asin(Math.max(-1, Math.min(1, y / geo.radius))) / (geo.theta * DEG);
      const delta = Math.round(rows);
      rootRef.current?.focus({ preventScroll: true });
      if (delta !== 0) goTo(targetKRef.current + delta, { dir: Math.sign(delta) });
      return;
    }
    const first = d.samples[0];
    const last = d.samples[d.samples.length - 1];
    const dt = Math.max(1, last.t - first.t) / 1000;
    const recent = performance.now() - last.t < 80;
    let v = recent ? -((last.y - first.y) / dt) / rowHeight : 0;
    v = Math.max(-60, Math.min(60, v));
    if (latest.current.flat) v = 0;
    const p = pos.get();
    goTo(p + v * FLICK_TAU, { velocity: v, dir: Math.sign(v || p - d.startPos) });
  };

  /* ---------------------------------- wheel --------------------------------- */

  React.useEffect(() => {
    const el = rootRef.current;
    if (!el) return;
    let acc = 0;
    let lastT = 0;
    const onWheel = (e: WheelEvent) => {
      if (disabled || drag.current) return;
      const { count: n, loop: isLoop } = latest.current;
      const dir = Math.sign(e.deltaY);
      if (!dir) return;
      const k = targetKRef.current;
      // At a hard stop, let the page scroll instead.
      if (!isLoop && ((dir < 0 && k <= 0) || (dir > 0 && k >= n - 1))) return;
      e.preventDefault();
      const now = performance.now();
      if (now - lastT > 220 || Math.sign(acc) !== dir) acc = 0;
      lastT = now;
      // Line/page deltas (mouse notches) step once per event; pixel deltas
      // (trackpads) accumulate so a long swipe walks several rows.
      if (e.deltaMode !== 0) {
        goTo(k + dir, { dir });
        return;
      }
      acc += e.deltaY;
      if (Math.abs(acc) >= 34) {
        acc = 0;
        goTo(k + dir, { dir });
      }
    };
    el.addEventListener("wheel", onWheel, { passive: false });
    return () => el.removeEventListener("wheel", onWheel);
  }, [disabled, goTo]);

  /* -------------------------------- keyboard -------------------------------- */

  const typeahead = React.useRef({ buffer: "", t: 0 });

  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
    if (disabled) return;
    const k = targetKRef.current;
    const n = count;
    // Spinbutton semantics: ArrowUp increases (next row). Listbox: ArrowUp = previous option.
    const up = isNumber ? 1 : -1;
    let handled = true;
    switch (e.key) {
      case "ArrowUp":
        goTo(k + up, { dir: up });
        break;
      case "ArrowDown":
        goTo(k - up, { dir: -up });
        break;
      case "PageUp":
        goTo(k + up * 5, { dir: up });
        break;
      case "PageDown":
        goTo(k - up * 5, { dir: -up });
        break;
      case "Home":
        goTo(nearestK(resolveEnabled(0, 1)), { dir: 1 });
        break;
      case "End":
        goTo(nearestK(resolveEnabled(n - 1, -1)), { dir: -1 });
        break;
      default:
        handled = false;
    }
    if (!handled && e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey && e.key !== " ") {
      const ta = typeahead.current;
      const now = performance.now();
      const ch = e.key.toLowerCase();
      ta.buffer = now - ta.t > 700 ? ch : ta.buffer + ch;
      ta.t = now;
      const current = mod(k, n);
      // Repeating one letter cycles through the rows that start with it.
      const cycling = ta.buffer.length > 1 && ta.buffer.split("").every((c) => c === ch);
      const needle = cycling ? ch : ta.buffer;
      const startAt = cycling || ta.buffer.length === 1 ? 1 : 0;
      for (let i = 0; i < n; i++) {
        const idx = mod(current + startAt + i, n);
        const row = rowAt(idx);
        if (!row.disabled && row.text.toLowerCase().startsWith(needle)) {
          goTo(nearestK(idx));
          break;
        }
      }
      handled = true;
    }
    if (handled) e.preventDefault();
  };

  /* --------------------------------- render --------------------------------- */

  const baseId = React.useId();
  const rowId = (k: number) => `${baseId}-row-${k}`;

  const ks: number[] = [];
  for (let k = centerK - g.reach; k <= centerK + g.reach; k++) {
    if (!loop && (k < 0 || k > count - 1)) continue;
    ks.push(k);
  }
  // The active descendant must always exist in the DOM, even mid-spin.
  if (!ks.includes(targetK)) ks.push(targetK);

  const p0 = pos.get();
  const bandTop = g.height / 2 - rowHeight / 2;
  const selectedRow = rowAt(selectedIndex);

  const renderRows = (lit: boolean) =>
    ks.map((k) => {
      const index = mod(k, count);
      const row = rowAt(index);
      // In loop mode the window can repeat options; only one copy of each is
      // exposed to assistive tech so the listbox never lists duplicates.
      const exposed =
        !lit &&
        !isNumber &&
        k >= targetK - Math.floor((count - 1) / 2) &&
        k <= targetK + Math.ceil((count - 1) / 2);
      return (
        <div
          key={k}
          data-tumbler-row={k}
          id={exposed ? rowId(k) : undefined}
          role={exposed ? "option" : undefined}
          aria-selected={exposed ? k === targetK : undefined}
          aria-disabled={exposed && row.disabled ? true : undefined}
          aria-setsize={exposed ? count : undefined}
          aria-posinset={exposed ? index + 1 : undefined}
          aria-hidden={exposed ? undefined : true}
          className={cn(
            "absolute inset-x-0 flex items-center justify-center whitespace-nowrap px-3 tabular-nums select-none",
            "[backface-visibility:hidden] will-change-transform",
            lit ? "font-medium text-white" : "text-neutral-500",
            row.disabled && "line-through opacity-40"
          )}
          style={{ top: bandTop, height: rowHeight, ...rowStyle(k - p0, g, flat) }}
        >
          {row.label}
        </div>
      );
    });

  // Width: every label stacked in one invisible grid cell (number mode only
  // needs the extremes; tabular digits keep the rest within them).
  const sizerLabels: React.ReactNode[] = listRows
    ? listRows.map((r) => r.label)
    : [rowAt(0).label, rowAt(count - 1).label];

  const bandMask = `linear-gradient(to bottom, #000 ${bandTop}px, transparent ${bandTop}px, transparent ${bandTop + rowHeight}px, #000 ${bandTop + rowHeight}px)`;
  const ariaName = ariaLabelledby ? undefined : label;

  const a11y = isNumber
    ? {
        role: "spinbutton" as const,
        "aria-valuenow": selectedRow.value as number,
        "aria-valuemin": min,
        "aria-valuemax": rowAt(count - 1).value as number,
        "aria-valuetext": selectedRow.text,
      }
    : {
        role: "listbox" as const,
        "aria-activedescendant": rowId(targetK),
      };

  return (
    <div
      ref={rootRef}
      data-crucible="tumbler"
      tabIndex={disabled ? -1 : 0}
      aria-label={ariaName}
      aria-labelledby={ariaLabelledby}
      aria-disabled={disabled || undefined}
      {...rest}
      {...a11y}
      onKeyDown={onKeyDown}
      onPointerDown={onPointerDown}
      onPointerMove={onPointerMove}
      onPointerUp={(e) => endDrag(e)}
      onPointerCancel={(e) => endDrag(e, true)}
      className={cn(
        "group/tumbler relative inline-grid shrink-0 cursor-grab touch-none rounded-xl text-[17px] outline-none active:cursor-grabbing",
        "focus-visible:ring-2 focus-visible:ring-(--tumbler-accent)/70 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950",
        disabled && "pointer-events-none opacity-45",
        className
      )}
      style={{
        ["--tumbler-accent" as string]: accentColor,
        height: g.height,
        ...style,
      }}
    >
      {/* Invisible sizer sets the drum width to its widest label. */}
      <div aria-hidden className="invisible col-start-1 row-start-1 grid h-0 overflow-hidden font-medium tabular-nums">
        {sizerLabels.map((l, i) => (
          <span key={i} className="col-start-1 row-start-1 whitespace-nowrap px-3">
            {l}
          </span>
        ))}
      </div>

      {!group && (
        <div
          aria-hidden
          className="pointer-events-none absolute inset-x-0 rounded-lg bg-white/[0.045]"
          style={{ top: bandTop, height: rowHeight }}
        >
          <BandRules />
        </div>
      )}

      {/* Base drum: everything outside the lit band. Rows are direct children
          so they render under this layer's perspective. */}
      <motion.div
        className="pointer-events-none absolute inset-0"
        style={{
          opacity: intro,
          perspective: flat ? undefined : g.perspective,
          maskImage: bandMask,
          WebkitMaskImage: bandMask,
        }}
      >
        {renderRows(false)}
      </motion.div>
      <motion.div
        aria-hidden
        className="pointer-events-none absolute inset-0"
        style={{
          opacity: intro,
          perspective: flat ? undefined : g.perspective,
          clipPath: `inset(${bandTop}px 0 ${g.height - bandTop - rowHeight}px 0)`,
        }}
      >
        {renderRows(true)}
      </motion.div>

      {name !== undefined && <input type="hidden" name={name} value={String(selectedRow.value)} />}
    </div>
  );
}

/** Accent hairlines above and below the lit band, fading at the ends. */
function BandRules() {
  const line =
    "absolute inset-x-1 h-px bg-[linear-gradient(90deg,transparent,var(--tumbler-accent)_22%,var(--tumbler-accent)_78%,transparent)] opacity-60";
  return (
    <>
      <span className={cn(line, "top-0")} />
      <span className={cn(line, "bottom-0")} />
    </>
  );
}

/* -------------------------------------------------------------------------- */
/*                                TumblerGroup                                */
/* -------------------------------------------------------------------------- */

/**
 * TumblerGroup — lays several `Tumbler` drums side by side (HH:MM, dates)
 * sharing one highlight band. Every drum inherits the group's `visibleRows`,
 * `rowHeight` and `accentColor` so their rows line up exactly. Renders
 * `role="group"`; each drum keeps its own label and keyboard focus (Tab moves
 * between drums).
 */
export function TumblerGroup({
  label,
  visibleRows = 5,
  rowHeight = 40,
  accentColor = DEFAULT_ACCENT,
  className,
  style,
  children,
  ...props
}: TumblerGroupProps) {
  const ctx = React.useMemo(
    () => ({ visibleRows, rowHeight, accentColor }),
    [visibleRows, rowHeight, accentColor]
  );
  return (
    <div
      role="group"
      aria-label={props["aria-labelledby"] ? undefined : label}
      data-crucible="tumbler-group"
      className={cn("relative isolate inline-flex items-center gap-1 px-2", className)}
      style={{ ["--tumbler-accent" as string]: accentColor, ...style }}
      {...props}
    >
      <div
        aria-hidden
        className="pointer-events-none absolute inset-x-0 top-1/2 -z-10 -translate-y-1/2 rounded-lg bg-white/[0.045]"
        style={{ height: rowHeight }}
      >
        <BandRules />
      </div>
      <TumblerGroupContext.Provider value={ctx}>{children}</TumblerGroupContext.Provider>
    </div>
  );
}
```
