Tumbler
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.
motionfree
"use client";
import { animate, motion, useMotionValue } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/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>
);
}
Installation
CLI
npx shadcn@latest add @crucible/tumblerManual — install dependencies, then copy the source
npm install motionProps
| 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 all props of React.HTMLAttributes<HTMLDivElement> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.