Wheel picker
A drum. Drag, flick, scroll or use the arrow keys: rows tilt away and fade toward the edges, the drum rubber-bands past the ends and settles on one row with a tick.
Code
1. Install
# No packages beyond React.
2. Files
"use client";
// WheelPicker: a drum. Drag, flick, scroll or use the arrow keys: rows tilt away and fade toward the edges, the drum
// rubber-bands past the ends and settles on one row with a tick. The position is painted straight onto the rows;
// React only hears about the selected one.
import { useEffect, useId, useRef, useState, type CSSProperties } from "react";
export type WheelPickerCue = "tactile-pip";
export type WheelPickerProps<T extends string = string> = {
/** The rows, top to bottom. */
options?: T[];
/** Controlled selected option. */
value?: T;
/** Uncontrolled initial option. */
defaultValue?: T;
/** Called with the option when the drum settles on a new row. */
onChange?: (value: T) => void;
/** Visible rows (odd looks best). */
rows?: number;
/** 0-100: how far a flick carries. */
momentum?: number;
/** Width in px. */
width?: number;
/** Accessible name of the listbox. */
label?: string;
/** Sound hook: "tactile-pip" on every row passed, pitched by position. */
onSound?: (cue: WheelPickerCue, opts?: { at?: number }) => void;
className?: string;
style?: CSSProperties;
};
const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
const TICK_GAP = 30; // ms between row ticks
const ROW = 36;
const BAND = 1.1; // rows of give past either end before the rubber band stiffens
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
export function WheelPicker<T extends string = string>({ options = MONTHS as T[], value, defaultValue = "April" as T, onChange, rows = 5, momentum = 50, width = 180, label = "Month", onSound, className, style }: WheelPickerProps<T>) {
const n = options.length;
const idx = (v: T | undefined) => Math.max(0, options.indexOf((v ?? options[0]) as T));
const start = idx(value ?? defaultValue);
// y = drawn position, raw = un-banded drag position. Both live here; React only hears about the selected row.
const st = useRef({ y: start, raw: start, v: 0, raf: 0, down: false, drag: false, y0: 0, lastY: 0, lastT: 0, tick: start, tickAt: 0, said: options[start] });
const [sel, setSel] = useState(start);
const root = useRef<HTMLDivElement>(null);
const els = useRef<(HTMLDivElement | null)[]>([]);
const id = useId();
const half = Math.floor(rows / 2);
const rowStyle = (d: number) => {
const a = clamp(d * 24, -80, 80);
const f = clamp(1 - Math.abs(d) / (half + 0.5), 0, 1);
return {
transform: `translateY(${(d * ROW * 0.92).toFixed(2)}px) rotateX(${(-a).toFixed(1)}deg) scale(${(0.82 + 0.18 * f).toFixed(3)})`,
opacity: (0.15 + 0.85 * f ** 1.4).toFixed(3),
filter: f > 0.995 ? "none" : `blur(${((1 - f) * 1.2).toFixed(2)}px)`,
visibility: Math.abs(d) > half + 1 ? ("hidden" as const) : ("visible" as const),
};
};
const paint = (v: number) => {
st.current.y = v;
els.current.forEach((el, i) => {
if (!el) return;
const d = i - v;
if (Math.abs(d) > half + 1.5 && el.style.visibility === "hidden") return;
Object.assign(el.style, rowStyle(d));
});
const t = Math.round(v);
if (t !== st.current.tick) {
st.current.tick = t;
const now = performance.now();
if (now - st.current.tickAt >= TICK_GAP) {
st.current.tickAt = now;
onSound?.("tactile-pip", { at: clamp(t / (n - 1), 0, 1) });
}
setSel(clamp(t, 0, n - 1));
}
};
// rubber band past the ends: v * tanh(x / v)
const band = (x: number) => {
if (x < 0) return -BAND * Math.tanh(-x / BAND);
if (x > n - 1) return n - 1 + BAND * Math.tanh((x - n + 1) / BAND);
return x;
};
const drag = (raw: number) => {
st.current.raw = raw;
paint(band(raw));
};
const settle = (to: number, v0: number) => {
const s = st.current;
cancelAnimationFrame(s.raf);
const target = clamp(Math.round(to), 0, n - 1);
s.v = v0;
let last = 0;
const f = (t: number) => {
const dt = last ? clamp((t - last) / 16.67, 0, 2.5) : 1;
last = t;
s.v += ((target - s.y) * 0.08 - s.v * 0.22) * dt;
const nx = s.y + s.v * dt;
if (Math.abs(target - nx) < 0.002 && Math.abs(s.v) < 0.002) {
paint(target);
s.raw = target;
s.raf = 0;
const v = options[target];
if (v !== s.said) {
s.said = v;
onChange?.(v);
}
return;
}
paint(nx);
s.raf = requestAnimationFrame(f);
};
s.raf = requestAnimationFrame(f);
};
const release = () => {
const s = st.current;
if (!s.down) return;
s.down = false;
if (!s.drag) return settle(s.y, 0); // a tap: finish any interrupted settle; a row click overrides it
s.drag = false;
const v = performance.now() - s.lastT > 90 ? 0 : clamp(s.v, -1.2, 1.2);
const reach = 4 + (clamp(momentum, 0, 100) / 100) * 14;
settle(s.y + v * reach, v * 0.6);
};
// the wheel listener is attached once; it reaches the latest closures through this ref
const live = useRef({ drag, settle });
live.current = { drag, settle };
useEffect(() => () => cancelAnimationFrame(st.current.raf), []);
useEffect(() => {
const el = root.current;
if (!el) return;
let t = 0;
const wheel = (e: WheelEvent) => {
e.preventDefault();
cancelAnimationFrame(st.current.raf);
if (!t) st.current.raw = st.current.y;
live.current.drag(st.current.raw + e.deltaY / ROW / 2);
clearTimeout(t);
t = window.setTimeout(() => {
t = 0;
live.current.settle(st.current.y, 0);
}, 90);
};
el.addEventListener("wheel", wheel, { passive: false });
return () => {
el.removeEventListener("wheel", wheel);
clearTimeout(t);
};
}, []);
// a controlled value moves the drum, unless the user is holding it
useEffect(() => {
if (value === undefined || st.current.down) return;
const to = options.indexOf(value);
if (to >= 0 && to !== Math.round(st.current.y)) {
st.current.said = value;
live.current.settle(to, 0);
}
}, [value, options]);
const y = st.current.y; // render from the live position so a re-render never fights the painted frame
return (
<div
ref={root}
className={["twp-root", className].filter(Boolean).join(" ")}
style={{ width, height: rows * ROW, ...style }}
role="listbox"
tabIndex={0}
aria-label={label}
aria-activedescendant={`${id}-${sel}`}
onPointerDown={(e) => {
const s = st.current;
cancelAnimationFrame(s.raf);
s.down = true;
s.drag = false;
s.v = 0;
s.raw = s.y;
s.y0 = s.lastY = e.clientY;
s.lastT = performance.now();
}}
onPointerMove={(e) => {
const s = st.current;
if (!s.down) return;
if (!s.drag) {
if (Math.abs(e.clientY - s.y0) < 4) return; // dead zone: a tap stays a click on the row
s.drag = true;
try {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {
// pointer already gone: the drag still starts
}
}
const now = performance.now();
const dy = -(e.clientY - s.lastY) / ROW;
const dt = Math.max(1, now - s.lastT) / 16.67;
s.v = s.v * 0.5 + (dy / dt) * 0.5;
s.lastY = e.clientY;
s.lastT = now;
drag(s.raw + dy);
}}
onPointerUp={release}
onPointerCancel={release}
onLostPointerCapture={release}
onKeyDown={(e) => {
const cur = Math.round(st.current.y);
const to = { ArrowDown: cur + 1, ArrowUp: cur - 1, Home: 0, End: n - 1 }[e.key];
if (to === undefined) return;
e.preventDefault();
settle(to, 0);
}}
>
<span className="twp-slot" aria-hidden style={{ height: ROW, top: half * ROW }} />
<div className="twp-list">
{options.map((o, i) => (
<div
key={o}
ref={(el) => void (els.current[i] = el)}
id={`${id}-${i}`}
role="option"
aria-selected={sel === i}
className="twp-row"
data-on={sel === i || undefined}
style={{ height: ROW, top: half * ROW, ...rowStyle(i - y) }}
onClick={() => settle(i, 0)}
>
{o}
</div>
))}
</div>
</div>
);
}
.twp-root,
.twp-root * {
box-sizing: border-box;
}
.twp-root {
font-family: var(--ts-font, system-ui, sans-serif);
position: relative;
overflow: hidden;
border-radius: var(--ts-r-pop);
background: var(--ts-raised);
box-shadow: var(--ts-edge);
color: var(--ts-fg);
touch-action: none;
user-select: none;
cursor: grab;
perspective: 500px;
mask-image: linear-gradient(transparent, #000 25%, #000 75%, transparent);
-webkit-tap-highlight-color: transparent;
}
.twp-root:active {
cursor: grabbing;
}
.twp-root:focus-visible {
outline: 0;
box-shadow: 0 0 0 2px var(--ts-raised), 0 0 0 4px var(--ts-accent);
}
.twp-slot {
position: absolute;
left: 8px;
right: 8px;
border-radius: calc(var(--ts-r-pop) - 8px);
background: rgba(var(--ts-fg-rgb), 0.06);
}
.twp-list {
position: absolute;
inset: 0;
transform-style: preserve-3d;
}
.twp-row {
position: absolute;
left: 0;
right: 0;
display: grid;
place-items: center;
font-size: 16px;
font-weight: 500;
letter-spacing: -0.01em;
color: rgba(var(--ts-fg-rgb), 0.62);
transform-origin: 50% 50%;
backface-visibility: hidden;
cursor: pointer;
}
.twp-row[data-on] {
color: var(--ts-fg);
}
/*
* Teseria tokens — the only colours, fonts and corner radii a block reads.
*
* Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
* the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
* any ancestor, or the OS setting unless [data-theme="light"] is set.
*
* Surfaces page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
* Text fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
* Lines border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
* dark; composes with other shadows)
* Signals accent · focus · success · warning · danger
* Corners controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
* (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
*/
:root {
--ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ts-r-panel: 24px;
--ts-r-pop: 18px;
--ts-r-inner: 12px;
--ts-page: #ffffff;
--ts-raised: #ffffff;
--ts-sunken: #f3f2f0;
--ts-tint-1: #f5f4f2;
--ts-tint-2: #ecebe8;
--ts-tint-3: #e3e1dd;
--ts-fg: #161514;
--ts-fg-rgb: 22, 21, 20;
--ts-fg-secondary: #3a3836;
--ts-fg-tertiary: #585552;
--ts-fg-muted: #6d6a66;
--ts-fg-faint: #89857f;
--ts-fg-inverse: #ffffff;
--ts-border: rgba(22, 21, 20, 0.1);
--ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);
--ts-accent: #3446eb;
--ts-focus: #3446eb;
--ts-success: #13804a;
--ts-warning: #a9650b;
--ts-danger: #df474e;
}
:root[data-theme="dark"] {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
}
3. Usage
import { WheelPicker } from "./WheelPicker";
import "./WheelPicker.css";
export default function Demo() {
return <WheelPicker />;
}