Teseria
All elements

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

Terminal
# No packages beyond React.

2. Files

WheelPicker.tsx232 lines
"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>
  );
}
WheelPicker.css55 lines
.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);
}
tokens.cssshared · add once
/*
 * 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

Demo.tsx
import { WheelPicker } from "./WheelPicker";
import "./WheelPicker.css";

export default function Demo() {
  return <WheelPicker />;
}