Wheel pickerNew

Drums you can fling: each column is a real cylinder that decays onto a row, with a lens in full colour over the middle.

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/wheel-picker.json

Overview

Web pickers are usually a select or a scrolling list with snap points that stutter. These are drums. Each column is a cylinder of faces turned by one transform, so the options tip away toward the rim as they leave the middle. The middle row is a second copy of the drum clipped to the band in full colour, so a number crossing the band's edge is half muted, half bright, like glass over the wheel. Drag and let go: the release speed is projected forward and the wheel decays onto a row instead of snapping, with a tick under the finger on phones that can vibrate. Wheels that don't loop stretch past their ends. The value is committed when the wheel rests, the unit beside the lens morphs with it, and options that can't be chosen are dimmed and passed over.

States

drag
The drum follows the pointer one row per row height. Past the ends of a wheel that doesn't loop it stretches with resistance, up to about one and a half rows.
fling
Release velocity from the last 90ms is projected 0.3s ahead and rounded to a row; the wheel decays onto it exponentially (τ 300ms, or 120ms for a short nudge), so it lands without a separate snap.
wheel and keys
A mouse notch or an arrow key moves one row (τ 110ms), Page Up and Down five. Trackpad scrolling turns the drum freely and settles 120ms after it goes quiet.
rest
The value is committed and announced as the wheel comes to rest. A unit beside the lens morphs with it, e.g. hour to hours. A dimmed option turns on to the nearest one that can be chosen.
set from outside
When the value changes from your code (a day that no longer exists in February), the wheel turns to it the shorter way round.

Usage

usage.tsx
import { Wheel, WheelPicker } from "@/components/wheel-picker";const hours = Array.from({ length: 12 }, (_, i) => i + 1);const minutes = Array.from({ length: 60 }, (_, i) => ({ value: i, label: String(i).padStart(2, "0") }));<WheelPicker aria-label="Alarm time">  <Wheel label="Hour" options={hours} value={hour} onValueChange={setHour} loop align="end" />  <Wheel label="Minute" options={minutes} value={minute} onValueChange={setMinute} loop align="start" />  <Wheel label="AM or PM" options={["AM", "PM"]} value={period} onValueChange={setPeriod} align="start" /></WheelPicker>

Works with

A date that can't be wrong

Days past the end of the month are disabled, so the wheel dims them and turns past them; when the month changes under a day that no longer exists, set the day and the wheel turns to it.

example.tsx
"use client";import * as React from "react";import { Wheel, WheelPicker } from "@/components/wheel-picker";const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];export function BirthdayField({ onChange }: { onChange: (date: Date) => void }) {  const [month, setMonth] = React.useState(0);  const [day, setDay] = React.useState(1);  const [year, setYear] = React.useState(1995);  const max = new Date(year, month + 1, 0).getDate();  const days = Array.from({ length: 31 }, (_, i) => ({ value: i + 1, disabled: i + 1 > max }));  React.useEffect(() => {    if (day > max) setDay(max);    else onChange(new Date(year, month, day));  }, [day, month, year, max, onChange]);  return (    <WheelPicker aria-label="Date of birth">      <Wheel label="Month" options={MONTHS.map((label, value) => ({ value, label }))} value={month} onValueChange={setMonth} loop align="start" />      <Wheel label="Day" options={days} value={Math.min(day, max)} onValueChange={setDay} loop />      <Wheel label="Year" options={Array.from({ length: 90 }, (_, i) => 1940 + i)} value={year} onValueChange={setYear} align="end" />    </WheelPicker>  );}

Props

PropTypeDescription
WheelPicker itemHeightnumberdefault 36Row height in px (28–56). The drum's radius, the band and the text size follow from it.
WheelPicker hapticsbooleandefault trueA 3ms vibration per row while a finger turns a wheel, on phones that support it.
options(T | { value: T; label?: string; disabled?: boolean })[]Strings, numbers or objects. Disabled options are dimmed and the wheel turns past them.
value / defaultValueTControlled or uncontrolled. A controlled change turns the wheel to the new option.
onValueChange(value: T) => voidCalled once the wheel comes to rest on a new option, not while it turns.
loopbooleandefault falseTurn past the end back to the start, like minutes or months.
labelstringWhat the wheel sets, for screen readers: "Hours".
unitstring | (value: T) => stringFixed beside the lens, e.g. "min", or a function for "1 hour" / "2 hours". It morphs when the value changes.
align"start" | "center" | "end"default "center"Where the options sit in their column.

Accessibility and motion

  • Each wheel is a spinbutton with aria-valuetext (the option and its unit). Arrow keys step, Page Up and Down jump five, Home and End go to the ends.
  • A tap on a row above or below the lens turns it to the middle; the row is found from the drum's angle, not the flat offset, so rows near the rim land correctly.
  • Every wheel has 18 faces whatever its length: only faces turning round the back are relabelled, so 60 minutes or 100 years cost the same. Turning writes two transforms a frame and renders nothing through React.
  • The drum leaves a hole under the lens through its mask, so the lens copy is the only text there; mouse wheels over the picker turn it instead of scrolling the page.
  • Reduced motion turns the wheel straight to the row instead of decaying onto it; dragging still follows the pointer.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs Text morph.

Source

components/wheel-picker.tsx
'use client';/* ───────────────────────────────────────────────────────── * WHEEL PICKER: drums you can fling * *   drum      every column is a real cylinder: options sit on *             its faces and tip away into the dark as they *             leave the middle, one transform a frame *   lens      the middle row is a separate copy of the drum, *             clipped to the band, in full colour; a letter *             crossing the band's edge is half one, half the *             other, like glass over the wheel *   fling     drag and let go: the release speed is projected *             forward and the wheel decays onto a row, with a *             tick on phones for each row it passes; past the *             ends of a wheel that doesn't loop it stretches *   settle    the value is committed once the wheel rests, and *             a unit beside the lens morphs with it (1 hour, *             2 hours). Options that can't be chosen are dimmed *             and the wheel turns on past them * * Each wheel is a spinbutton: arrows step, Page Up and Down * jump five, Home and End go to the ends; a mouse notch moves * one row and taps on a row bring it to the middle. * ───────────────────────────────────────────────────────── */import * as React from 'react';import { TextMorph } from './text-morph';import { useInteractionReducedMotion } from './ui-interaction-motion';import { SLOTS, STEP, useWheelDrum } from './use-wheel-drum';export type WheelValue = string | number;export type WheelOption<T extends WheelValue = WheelValue> = T | { value: T; label?: string; disabled?: boolean };export interface WheelPickerProps extends React.HTMLAttributes<HTMLDivElement> {  /** Row height in px; the drum's radius follows from it. */  itemHeight?: number;  /** Ticks on phones that can vibrate, one per row. */  haptics?: boolean;  children: React.ReactNode;}export interface WheelProps<T extends WheelValue = WheelValue> {  options: readonly WheelOption<T>[];  value?: NoInfer<T>;  defaultValue?: NoInfer<T>;  onValueChange?: (value: NoInfer<T>) => void;  /** Turn past the end back to the start, like minutes. */  loop?: boolean;  /** What the wheel sets, for screen readers: "Hours". */  label: string;  /** Fixed beside the lens: "min", or a function for "1 hour" / "2 hours". */  unit?: string | ((value: NoInfer<T>) => string);  /** Where the options sit in their column. */  align?: 'start' | 'center' | 'end';  className?: string;  style?: React.CSSProperties;}const Ctx = React.createContext({ itemHeight: 36, haptics: true });const PERSPECTIVE = 1000;const valueOf = <T extends WheelValue>(o: WheelOption<T>) => (typeof o === 'object' ? o.value : o);const labelOf = <T extends WheelValue>(o: WheelOption<T>) => (typeof o === 'object' ? (o.label ?? String(o.value)) : String(o));const offOf = <T extends WheelValue>(o: WheelOption<T>) => typeof o === 'object' && Boolean(o.disabled);export function WheelPicker({ itemHeight = 36, haptics = true, className = '', style, children, ...props }: WheelPickerProps) {  const H = Math.max(28, Math.min(56, itemHeight));  const radius = H / (2 * Math.tan((STEP / 2) * (Math.PI / 180)));  const ctx = React.useMemo(() => ({ itemHeight: H, haptics }), [H, haptics]);  return (    <Ctx.Provider value={ctx}>      <div role="group" className={`relative flex w-full select-none justify-center px-2 ${className}`} style={{ height: Math.round(radius * 2), ...style }} {...props}>        <div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 rounded-[11px] bg-muted" style={{ height: H }} />        {children}      </div>    </Ctx.Provider>  );}export function Wheel<T extends WheelValue = WheelValue>({  options,  value,  defaultValue,  onValueChange,  loop = false,  label,  unit,  align = 'center',  className = '',  style,}: WheelProps<T>) {  const { itemHeight: H, haptics } = React.useContext(Ctx);  const reduced = useInteractionReducedMotion();  const count = options.length;  const indexOf = (v: T | undefined) => Math.max(0, options.findIndex((o) => valueOf(o) === v));  const [own, setOwn] = React.useState<T | undefined>(defaultValue ?? (count ? valueOf(options[0]) : undefined));  const current = value !== undefined ? value : own;  const index = indexOf(current);  const optionsRef = React.useRef(options);  optionsRef.current = options;  const report = React.useRef(onValueChange);  report.current = onValueChange;  const controlled = value !== undefined;  const committed = React.useRef(index);  const drum = useWheelDrum(index, {    count,    loop,    itemHeight: H,    reduced,    haptics,    label: (i) => labelOf(optionsRef.current[i]),    disabled: (i) => offOf(optionsRef.current[i]),    onRest: (i) => {      if (i === committed.current) return;      committed.current = i;      const next = valueOf(optionsRef.current[i]);      if (!controlled) setOwn(next);      report.current?.(next);    },  });  // The value changed from outside (another wheel clamped it, or the app set it): turn to it.  const first = React.useRef(true);  React.useEffect(() => {    if (first.current) { first.current = false; return; }    if (index === committed.current) return;    committed.current = index;    drum.turnTo(index, !reduced);    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [index]);  // Labels or disabled options changed: relabel the faces in place.  const labels = options.map((o) => `${labelOf(o)}${offOf(o) ? '·' : ''}`).join('|');  React.useEffect(() => { drum.repaint(); }, [labels]); // eslint-disable-line react-hooks/exhaustive-deps  // Faces are labelled once here; the drum relabels them directly as it turns.  const [faceText] = React.useState(() =>    Array.from({ length: SLOTS }, (_, k) => {      const i = k + SLOTS * Math.round((index - k) / SLOTS);      const real = loop ? ((i % count) + count) % count : i;      return count && (loop || (i >= 0 && i < count)) ? labelOf(options[real]) : '';    }),  );  const unitOf = (v: T) => (typeof unit === 'function' ? unit(v) : (unit ?? ''));  const unitText = current !== undefined && unit ? unitOf(current) : '';  const widest = unit ? options.map((o) => unitOf(valueOf(o))).reduce((a, b) => (b.length > a.length ? b : a), '') : '';  const onKeyDown = (e: React.KeyboardEvent) => {    const moves: Record<string, number> = { ArrowUp: -1, ArrowDown: 1, PageUp: -5, PageDown: 5 };    if (e.key in moves) {      e.preventDefault();      drum.nudge(moves[e.key]);    } else if (e.key === 'Home' || e.key === 'End') {      e.preventDefault();      const to = e.key === 'Home' ? 0 : count - 1;      drum.turnTo(to, !reduced);    }  };  const justify = align === 'start' ? 'justify-start' : align === 'end' ? 'justify-end' : 'justify-center';  const faceClass = `absolute inset-x-0 flex items-center whitespace-nowrap px-2.5 tabular-nums [backface-visibility:hidden] data-[off]:opacity-35 ${justify}`;  const face = (k: number) => ({ top: -H / 2, height: H, transform: `rotateX(${-k * STEP}deg) translateZ(${drum.radius}px)` });  const edge = `calc(50% - ${H / 2}px)`, edge2 = `calc(50% + ${H / 2}px)`;  // The drum fades toward its rim and leaves a hole where the lens copy sits.  const mask = `linear-gradient(to bottom, transparent 8%, #000 36%, #000 ${edge}, transparent ${edge}, transparent ${edge2}, #000 ${edge2}, #000 64%, transparent 92%)`;  // The first frame's rotation; after that the drum writes its own transform, so React must never touch it.  const [current3d] = React.useState(() => ({ transformStyle: 'preserve-3d' as const, transform: `translateZ(${-drum.radius}px) rotateX(${index * STEP}deg)` }));  return (    <div      ref={drum.column}      role="spinbutton"      tabIndex={0}      aria-label={label}      aria-valuenow={index}      aria-valuemin={0}      aria-valuemax={Math.max(0, count - 1)}      aria-valuetext={`${count ? labelOf(options[index]) : ''}${unitText ? ` ${unitText}` : ''}`}      onKeyDown={onKeyDown}      className={`group relative flex h-full min-w-0 flex-1 cursor-grab touch-none items-center outline-none active:cursor-grabbing ${className}`}      style={style}    >      <div className="relative h-full min-w-0 flex-1 tracking-[-0.01em]" style={{ fontSize: Math.round(H * 0.56) }}>        <div aria-hidden="true" className="absolute inset-0 text-muted-foreground" style={{ perspective: PERSPECTIVE, maskImage: mask, WebkitMaskImage: mask }}>          <div ref={drum.drum} className="absolute inset-x-0 top-1/2 h-0 will-change-transform" style={current3d}>            {faceText.map((t, k) => (              <span key={k} ref={(el) => { drum.faces.current[k] = el; }} className={faceClass} style={face(k)}>{t}</span>            ))}          </div>        </div>        <div          aria-hidden="true"          className="absolute inset-x-0 top-1/2 -translate-y-1/2 overflow-hidden rounded-[11px] text-foreground group-focus-visible:ring-2 group-focus-visible:ring-ring/50"          style={{ height: H, perspective: PERSPECTIVE }}        >          <div ref={drum.lens} className="absolute inset-x-0 top-1/2 h-0 will-change-transform" style={current3d}>            {faceText.map((t, k) => (              <span key={k} ref={(el) => { drum.lensFaces.current[k] = el; }} className={faceClass} style={face(k)}>{t}</span>            ))}          </div>        </div>      </div>      {unit && (        <span aria-hidden="true" className="relative grid shrink-0 pr-2.5 text-[14px] font-medium text-foreground">          <span className="invisible col-start-1 row-start-1 whitespace-nowrap">{widest}</span>          <span className="col-start-1 row-start-1 whitespace-nowrap"><TextMorph animateWidth={false}>{unitText}</TextMorph></span>        </span>      )}    </div>  );}
components/use-wheel-drum.ts
'use client';import * as React from 'react';/* * One wheel's motion engine. The options sit on a ring of SLOTS faces round a cylinder; turning * the wheel is one rotateX on the drum (and the same on its copy in the lens), so a fling costs two * transforms a frame. Only faces turning round the back are relabelled, so a wheel of 60 minutes or * 200 years still has 18 faces. Nothing renders through React while it turns: the value is committed * once the wheel comes to rest. * *   drag     follows the finger one row per row height; past the ends of a wheel that doesn't *            loop it stretches with resistance *   fling    release velocity is projected forward and rounded to a row, then the wheel decays *            onto it exponentially, so it lands without a separate snap *   wheel    a mouse notch moves one row; trackpad scrolling turns it freely and settles when quiet *   tap      a row above or below the lens turns to the middle */export const SLOTS = 18;export const STEP = 360 / SLOTS;export type DrumOptions = {  count: number;  loop: boolean;  itemHeight: number;  reduced: boolean;  haptics: boolean;  label: (index: number) => string;  disabled: (index: number) => boolean;  /** The wheel came to rest on this option. */  onRest: (index: number) => void;};const mod = (n: number, m: number) => ((n % m) + m) % m;const rubber = (x: number) => (1 - 1 / (x * 0.6 + 1)) / 0.6;export function useWheelDrum(initial: number, options: DrumOptions) {  const opts = React.useRef(options);  opts.current = options;  const column = React.useRef<HTMLDivElement>(null);  const drum = React.useRef<HTMLDivElement>(null);  const lens = React.useRef<HTMLDivElement>(null);  const faces = React.useRef<(HTMLElement | null)[]>([]);  const lensFaces = React.useRef<(HTMLElement | null)[]>([]);  const shown = React.useRef<number[]>([]);  const sim = React.useRef({ p: initial, from: initial, target: initial, tau: 0.3, t0: 0, settling: false, rested: initial, tick: Math.round(initial), buzz: 0, touch: false });  const frame = React.useRef(0);  const radius = options.itemHeight / (2 * Math.tan((STEP / 2) * (Math.PI / 180)));  /** Write the drum's rotation and relabel the faces that crossed the back. */  const paint = React.useCallback((force = false) => {    const { count, loop, label, disabled, haptics } = opts.current;    const s = sim.current;    const r = opts.current.itemHeight / (2 * Math.tan((STEP / 2) * (Math.PI / 180)));    const transform = `translateZ(${-r}px) rotateX(${s.p * STEP}deg)`;    if (drum.current) drum.current.style.transform = transform;    if (lens.current) lens.current.style.transform = transform;    for (let k = 0; k < SLOTS; k++) {      const i = k + SLOTS * Math.round((s.p - k) / SLOTS);      if (!force && shown.current[k] === i) continue;      shown.current[k] = i;      const real = loop ? mod(i, count) : i;      const empty = !loop && (i < 0 || i >= count);      const text = empty || !count ? '' : label(real);      const off = !empty && count > 0 && disabled(real);      for (const el of [faces.current[k], lensFaces.current[k]]) {        if (!el) continue;        if (el.textContent !== text) el.textContent = text;        el.toggleAttribute('data-off', off);      }    }    // A tick under the finger for every row that passes the lens (phones that support it).    const row = Math.round(s.p);    if (row !== s.tick) {      s.tick = row;      const now = performance.now();      if (haptics && s.touch && now - s.buzz > 45 && typeof navigator !== 'undefined' && 'vibrate' in navigator) {        s.buzz = now;        try { navigator.vibrate(3); } catch { /* not allowed yet */ }      }    }  }, []);  const rest = React.useCallback(() => {    const { count, loop, disabled, onRest } = opts.current;    const s = sim.current;    s.settling = false;    if (!count) return;    let index = loop ? mod(Math.round(s.p), count) : Math.max(0, Math.min(count - 1, Math.round(s.p)));    // Landed on an option that can't be chosen: turn on to the nearest one that can.    if (disabled(index)) {      for (let d = 1; d < count; d++) {        const down = index - d, up = index + d;        const ok = (i: number) => (loop || (i >= 0 && i < count)) && !disabled(mod(i, count));        const next = ok(down) ? down : ok(up) ? up : null;        if (next !== null) {          settleTo(Math.round(s.p) + (next - index), 0.16);          return;        }      }      return;    }    s.rested = index;    onRest(index);    // eslint-disable-next-line react-hooks/exhaustive-deps  }, []);  const step = React.useCallback((now: number) => {    frame.current = 0;    const s = sim.current;    if (!s.settling) return;    const t = (now - s.t0) / 1000;    const k = Math.exp(-t / s.tau);    s.p = s.target + (s.from - s.target) * k;    if (Math.abs(s.p - s.target) < 0.002) {      s.p = s.target;      paint();      rest();      return;    }    paint();    frame.current = requestAnimationFrame(step);    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [paint]);  function settleTo(target: number, tau: number) {    const s = sim.current;    s.from = s.p;    s.target = target;    s.tau = tau;    s.t0 = performance.now();    s.settling = true;    if (opts.current.reduced) {      s.p = target;      paint();      rest();      return;    }    cancelAnimationFrame(frame.current);    frame.current = requestAnimationFrame(step);  }  const clampTarget = (target: number) => {    const { count, loop } = opts.current;    return loop ? target : Math.max(0, Math.min(count - 1, target));  };  /** Turn to an option. Loops take the shorter way round. */  const turnTo = React.useCallback((index: number, animate = true) => {    const { count, loop } = opts.current;    const s = sim.current;    if (!count) return;    let target = index;    if (loop) {      const base = Math.round(s.settling ? s.target : s.p);      const delta = mod(index - mod(base, count) + count / 2, count) - count / 2;      target = base + Math.round(delta);    }    s.rested = mod(index, count);    if (!animate) {      cancelAnimationFrame(frame.current);      frame.current = 0;      s.settling = false;      s.p = s.target = target;      paint();      return;    }    settleTo(target, 0.16);    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [paint]);  /** Move by whole rows from wherever the wheel is heading (keys, mouse notches). */  const nudge = React.useCallback((rows: number) => {    const s = sim.current;    s.touch = false;    const base = s.settling ? s.target : Math.round(s.p);    settleTo(clampTarget(base + rows), 0.11);    // eslint-disable-next-line react-hooks/exhaustive-deps  }, []);  // Pointer, wheel and the initial paint.  React.useEffect(() => {    const el = column.current;    if (!el) return;    paint(true);    let drag: { id: number; y: number; p: number; moved: boolean; samples: { y: number; t: number }[] } | null = null;    let quiet: ReturnType<typeof setTimeout> | null = null;    const s = sim.current;    const H = () => opts.current.itemHeight;    const down = (e: PointerEvent) => {      if (e.button !== 0 || drag) return;      el.setPointerCapture(e.pointerId);      cancelAnimationFrame(frame.current);      frame.current = 0;      s.settling = false;      s.touch = e.pointerType === 'touch';      drag = { id: e.pointerId, y: e.clientY, p: s.p, moved: false, samples: [{ y: e.clientY, t: e.timeStamp }] };    };    const move = (e: PointerEvent) => {      if (!drag || e.pointerId !== drag.id) return;      const dy = e.clientY - drag.y;      if (!drag.moved && Math.abs(dy) < 3) return;      drag.moved = true;      drag.samples.push({ y: e.clientY, t: e.timeStamp });      while (drag.samples.length > 2 && e.timeStamp - drag.samples[0].t > 90) drag.samples.shift();      const { count, loop } = opts.current;      let p = drag.p - dy / H();      if (!loop) {        if (p < 0) p = -rubber(-p);        else if (p > count - 1) p = count - 1 + rubber(p - (count - 1));      }      s.p = p;      paint();    };    const up = (e: PointerEvent) => {      if (!drag || e.pointerId !== drag.id) return;      const d = drag;      drag = null;      if (!d.moved) {        // A tap above or below the lens turns that row to the middle.        const r = el.getBoundingClientRect();        const r0 = opts.current.itemHeight / (2 * Math.tan((STEP / 2) * (Math.PI / 180)));        const off = (Math.asin(Math.max(-1, Math.min(1, (e.clientY - (r.top + r.height / 2)) / r0))) * 180) / Math.PI / STEP;        const rows = Math.round(off);        if (rows) settleTo(clampTarget(Math.round(s.p) + rows), 0.16);        else settleTo(clampTarget(Math.round(s.p)), 0.12);        return;      }      const first = d.samples[0], last = d.samples[d.samples.length - 1];      const fresh = e.timeStamp - last.t < 80 && last.t > first.t;      const velocity = fresh ? -((last.y - first.y) / (last.t - first.t)) * (1000 / H()) : 0; // rows per second      const { count, loop } = opts.current;      const outside = !loop && (s.p < 0 || s.p > count - 1);      const projected = outside ? s.p : s.p + velocity * 0.3;      const target = clampTarget(Math.round(projected));      settleTo(target, outside ? 0.14 : Math.abs(target - s.p) < 0.75 ? 0.12 : 0.3);    };    const wheel = (e: WheelEvent) => {      e.preventDefault();      s.touch = false;      const px = e.deltaMode === 1 ? e.deltaY * H() : e.deltaMode === 2 ? e.deltaY * H() * 5 : e.deltaY;      // A mouse wheel notch is one row; a trackpad turns the wheel freely and settles once it goes quiet.      if (Math.abs(px) >= 50 && Number.isInteger(e.deltaY)) {        nudge(Math.sign(px));        return;      }      cancelAnimationFrame(frame.current);      frame.current = 0;      s.settling = false;      const { count, loop } = opts.current;      let p = s.p + px / H();      if (!loop) p = Math.max(-0.35, Math.min(count - 1 + 0.35, p));      s.p = p;      paint();      if (quiet) clearTimeout(quiet);      quiet = setTimeout(() => settleTo(clampTarget(Math.round(s.p)), 0.14), 120);    };    el.addEventListener('pointerdown', down);    el.addEventListener('pointermove', move);    el.addEventListener('pointerup', up);    el.addEventListener('pointercancel', up);    el.addEventListener('wheel', wheel, { passive: false });    return () => {      if (quiet) clearTimeout(quiet);      cancelAnimationFrame(frame.current);      frame.current = 0;      el.removeEventListener('pointerdown', down);      el.removeEventListener('pointermove', move);      el.removeEventListener('pointerup', up);      el.removeEventListener('pointercancel', up);      el.removeEventListener('wheel', wheel);    };    // eslint-disable-next-line react-hooks/exhaustive-deps  }, []);  // Options changed (a month with fewer days): relabel every face.  const signature = `${options.count}|${options.loop}`;  React.useEffect(() => {    paint(true);  }, [signature, paint]);  return { column, drum, lens, faces, lensFaces, radius, turnTo, nudge, repaint: () => paint(true), resting: () => sim.current.rested, moving: () => sim.current.settling };}
components/ui-interaction-motion.ts
'use client';import * as React from 'react';export const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';export const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';export const INTERACTION_FOCUS = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background';const query = '(prefers-reduced-motion: reduce)';const subscribe = (notify: () => void) => {  const media = window.matchMedia(query);  media.addEventListener('change', notify);  return () => media.removeEventListener('change', notify);};export const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);export function useInteractionActivity<T extends Element>() {  const ref = React.useRef<T>(null);  const [visible, setVisible] = React.useState(false);  const [hidden, setHidden] = React.useState(false);  React.useEffect(() => {    const el = ref.current;    if (!el) return;    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));    observer.observe(el);    const visibility = () => setHidden(document.hidden);    visibility(); document.addEventListener('visibilitychange', visibility);    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };  }, []);  return { ref, active: visible && !hidden };}export function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {  return { opacity: on ? 1 : 0, transform: on ? 'none' : 'scale(0.6)', filter: on ? 'blur(0px)' : 'blur(3px)', transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}` };}/** Sample recent movement rather than averaging a long drag and losing its release velocity. */export type PointerSample = { x: number; time: number };export function samplePointer(samples: PointerSample[], x: number, time: number) {  samples.push({ x, time });  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();}export function releaseVelocity(samples: PointerSample[], now: number) {  if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0;  const first = samples[0], last = samples[samples.length - 1];  return (last.x - first.x) / Math.max(1, last.time - first.time);}/** A damped spring sampled by WAAPI: no React renders or perpetual frame loop. */export function springFrames(from: number, to: number, velocity = 0, duration = 520) {  const omega = 20, damping = 0.82, damped = omega * Math.sqrt(1 - damping * damping);  const displacement = from - to;  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;  return Array.from({ length: 33 }, (_, i) => {    const t = (i / 32) * duration / 1000;    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));    return { value: i === 32 ? to : value, offset: i / 32 };  });}

Registry item: /r/wheel-picker.json