# Inertial Wheel List

An iOS picker drum built on native scroll-snap, with selection derived from scrollTop.

- Demo: https://lab.moumen.dev/components/inertial-wheel-list
- Install: `npx moumenlab add inertial-wheel-list` — or `npx shadcn@latest add https://lab.moumen.dev/r/inertial-wheel-list.json`
- Dependencies: motion
- Registry dependencies: https://lab.moumen.dev/r/lab-theme.json
- Installs to: `components/lab/inertial-wheel-list.tsx`

## Usage

```tsx
"use client";

import WheelList from "./inertial-wheel-list";

// Every quarter hour of the day; the wheel starts on 9:00 AM (index 36).
export const TIMES: string[] = [];
for (let m = 0; m < 24 * 60; m += 15) {
  const h = Math.floor(m / 60);
  TIMES.push(`${h % 12 === 0 ? 12 : h % 12}:${String(m % 60).padStart(2, "0")} ${h < 12 ? "AM" : "PM"}`);
}

export default function WheelListExample() {
  return (
    <div className="flex flex-wrap items-start justify-center gap-6">
      {/* Default: the 3D drum. Selection derives from the scroll position and
          commits when the snap settles. */}
      <WheelList
        items={TIMES}
        label="Pick a start time"
        initialIndex={36}
        onStateChange={(state) => console.log(state.settled ? `selected ${state.value}` : "coasting")}
      />

      {/* drum={false}: a flat wheel — keeps the scale + fade, drops the rotateX. */}
      <WheelList items={TIMES} label="Pick a start time" initialIndex={36} drum={false} />
    </div>
  );
}
```

## Source — `components/lab/inertial-wheel-list.tsx`

```tsx
"use client";

import {
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
} from "react";
import { motion, useReducedMotion, useScroll, useTransform, type MotionValue } from "motion/react";

// Inertial wheel list — the iOS picker drum, rebuilt on one principle: the
// SCROLL POSITION IS THE STATE. Nothing is scroll-jacked and no library fakes
// the physics: the scroller is a plain overflow-y list, so the browser (and a
// thumb on a phone) owns momentum, and `scroll-snap-type: y mandatory` +
// `scroll-snap-align: center` land every fling on an item. The selection is
// DERIVED from scrollTop — round(scrollTop / itemHeight) — never stored beside
// it, so the two can't disagree.
//
// The drum look is paint, not layout: motion's `useScroll` tracks the scroller
// and every item derives `rotateX(±38° · t) scale(1.14 → 0.80)` and opacity from
// its distance to the viewport's centre via `useTransform` — motion values
// update outside the React render loop, GPU-composited, no per-frame setState.
// The edge fade is a `mask-image` gradient on the scroller, so items dissolve at
// the rim instead of clipping.
//
// Settling: `scrollend` fires when the snap lands, but not in every engine, so
// a 140ms quiet-timer fallback commits the same selection. Keyboard follows the
// listbox pattern — the scroller is the single tab stop, arrows/Home/End scroll
// to the neighbour (which updates selection because selection IS scroll) and
// aria-activedescendant tracks the centre item. Honours prefers-reduced-motion:
// transforms stay flat, programmatic scrolls jump.
//
// Geometry lives in three CSS custom properties on the root (--wheel-w/-h/-item)
// so the same wheel is fluid on a phone. Fully Tailwind; animation via motion/react.

const WHEEL_VARS = {
  "--wheel-w": "14rem",
  "--wheel-h": "12.5rem",
  "--wheel-item": "2.5rem",
} as CSSProperties;

export interface WheelState {
  value: string;
  index: number;
  count: number;
  settled: boolean;
}

interface Metrics {
  itemH: number;
  centers: number[];
  half: number;
}

// Fallback geometry (16px root): item 40px, viewport 200px — used until the
// first real measure lands.
const fallbackMetrics = (count: number): Metrics => ({
  itemH: 40,
  centers: Array.from({ length: count }, (_, i) => 80 + 40 * i + 20),
  half: 100,
});

export default function WheelList({
  items,
  label = "Pick a value",
  initialIndex = 0,
  drum = true,
  inspect = false,
  onStateChange,
}: {
  items: string[];
  label?: string;
  initialIndex?: number;
  drum?: boolean;
  inspect?: boolean;
  onStateChange?: (state: WheelState) => void;
}) {
  const scrollerRef = useRef<HTMLDivElement>(null);
  const settleTimer = useRef(0);
  const idBase = useId();
  const reduced = useReducedMotion();

  const [metrics, setMetrics] = useState<Metrics>(() => fallbackMetrics(items.length));
  const [index, setIndex] = useState(initialIndex);
  const [settled, setSettled] = useState(true);
  const indexRef = useRef(initialIndex);
  const metricsRef = useRef(metrics);
  metricsRef.current = metrics;

  const { scrollY } = useScroll({ container: scrollerRef });

  const clampIndex = useCallback(
    (i: number) => Math.min(Math.max(i, 0), items.length - 1),
    [items.length],
  );

  function handleScroll() {
    // Selection derives from the scroll on every frame; it commits when the
    // snap lands (scrollend where the engine has it, the quiet-timer elsewhere).
    const scroller = scrollerRef.current;
    if (scroller) {
      const next = clampIndex(Math.round(scroller.scrollTop / metricsRef.current.itemH));
      if (next !== indexRef.current) {
        indexRef.current = next;
        setIndex(next);
      }
    }
    setSettled(false);
    window.clearTimeout(settleTimer.current);
    settleTimer.current = window.setTimeout(() => setSettled(true), 140);
  }

  function handleScrollEnd() {
    window.clearTimeout(settleTimer.current);
    setSettled(true);
  }

  function scrollToIndex(i: number, smooth = true) {
    const scroller = scrollerRef.current;
    if (!scroller) return;
    scroller.scrollTo({
      top: clampIndex(i) * metricsRef.current.itemH,
      behavior: smooth && !reduced ? "smooth" : "auto",
    });
  }

  function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    const steps: Record<string, number> = { ArrowUp: -1, ArrowDown: 1, PageUp: -5, PageDown: 5 };
    let target: number;
    if (event.key in steps) target = indexRef.current + steps[event.key];
    else if (event.key === "Home") target = 0;
    else if (event.key === "End") target = items.length - 1;
    else return;
    event.preventDefault();
    scrollToIndex(target);
  }

  // Measure once (and on resize / new items): item height, each centre, and the
  // half-viewport — cached so the motion transforms never read layout.
  useLayoutEffect(() => {
    const scroller = scrollerRef.current;
    if (!scroller) return undefined;
    const measure = () => {
      const options = scroller.querySelectorAll<HTMLElement>('[role="option"]');
      if (!options.length) return;
      setMetrics({
        itemH: options[0].offsetHeight,
        centers: Array.from(options, (el) => el.offsetTop + el.offsetHeight / 2),
        half: scroller.clientHeight / 2,
      });
    };
    measure();
    const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(measure) : null;
    observer?.observe(scroller);
    return () => observer?.disconnect();
  }, [items]);

  // Land on the initial value before first paint — no snap animation on load.
  useLayoutEffect(() => {
    const scroller = scrollerRef.current;
    if (scroller) scroller.scrollTop = clampIndex(initialIndex) * metricsRef.current.itemH;
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  useEffect(() => () => window.clearTimeout(settleTimer.current), []);

  useEffect(() => {
    onStateChange?.({ value: items[index], index, count: items.length, settled });
  }, [items, index, settled, onStateChange]);

  const optionId = (i: number) => `${idBase}-opt-${i}`;
  const value = items[index];

  return (
    <div className="relative w-full max-w-[var(--wheel-w)]" style={WHEEL_VARS}>
      <div className="relative p-2 rounded-[1.25rem] bg-white shadow-border">
        {/* The selection lens: a static bar the centred item scrolls through. */}
        <span
          className="absolute left-2 right-2 top-1/2 h-[var(--wheel-item)] -translate-y-1/2 rounded-xl bg-gray-100 pointer-events-none"
          aria-hidden="true"
        />
        <div
          className="relative h-[var(--wheel-h)] overflow-y-auto overscroll-contain rounded-xl [scroll-snap-type:y_mandatory] [perspective:44rem] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden [mask-image:linear-gradient(to_bottom,transparent_0,#000_30%,#000_70%,transparent_100%)] [-webkit-mask-image:linear-gradient(to_bottom,transparent_0,#000_30%,#000_70%,transparent_100%)] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#111]"
          ref={scrollerRef}
          role="listbox"
          aria-label={label}
          aria-activedescendant={optionId(index)}
          tabIndex={0}
          data-drum={drum ? "true" : "false"}
          onScroll={handleScroll}
          onScrollEnd={handleScrollEnd}
          onKeyDown={handleKeyDown}
        >
          <ul className="[padding-block:calc((var(--wheel-h)-var(--wheel-item))/2)]">
            {items.map((item, i) => (
              <Option
                key={item}
                id={optionId(i)}
                label={item}
                selected={i === index}
                scrollY={scrollY}
                center={metrics.centers[i] ?? fallbackMetrics(items.length).centers[i]}
                half={metrics.half}
                drum={drum}
                flat={Boolean(reduced)}
                onClick={() => scrollToIndex(i)}
              />
            ))}
          </ul>
        </div>
        {inspect && (
          <>
            <span
              className="absolute left-1 right-1 top-1/2 z-[5] border-t-[1.5px] border-dashed border-[#ef4444] pointer-events-none"
              aria-hidden="true"
            />
            <span className="absolute z-[6] top-1 left-1/2 -translate-x-1/2 px-[0.3125rem] py-[0.0625rem] text-[0.625rem] leading-normal font-medium tracking-[0.01em] whitespace-nowrap bg-white rounded-[0.25rem] shadow-[0_1px_2px_rgba(0,0,0,0.08)] pointer-events-none tabular-nums text-[#dc2626] border border-[#fecaca]">
              {drum ? "rotateX(38° · t) · " : ""}scale(1.14 − 0.34|t|)
            </span>
            <span className="absolute z-[6] bottom-1 left-1/2 -translate-x-1/2 px-[0.3125rem] py-[0.0625rem] text-[0.625rem] leading-normal font-medium tracking-[0.01em] whitespace-nowrap bg-white rounded-[0.25rem] shadow-[0_1px_2px_rgba(0,0,0,0.08)] pointer-events-none tabular-nums text-[#2563eb] border border-[#bfdbfe]">
              index = round(scrollTop / {metrics.itemH}px) · snap mandatory
            </span>
          </>
        )}
      </div>

      <p className="sr-only" role="status" aria-live="polite">
        {settled ? `Selected ${value}` : "Scrolling"}
      </p>
    </div>
  );
}

// One drum row. `t` is the item's signed distance from the viewport centre in
// half-viewport units — the centre item is biggest (scale 1.14) and the rim
// dissolves, by continuous function rather than a styled selected class. All
// four styles are motion values derived from the scroll: no React re-render,
// no layout read, GPU-composited.
function Option({
  id,
  label,
  selected,
  scrollY,
  center,
  half,
  drum,
  flat,
  onClick,
}: {
  id: string;
  label: string;
  selected: boolean;
  scrollY: MotionValue<number>;
  center: number;
  half: number;
  drum: boolean;
  flat: boolean;
  onClick: () => void;
}) {
  const t = useTransform(scrollY, (v) => Math.max(-1, Math.min(1, (center - (v + half)) / half)));
  const rotateX = useTransform(t, (tv) => (flat || !drum ? 0 : -38 * tv));
  const scale = useTransform(t, (tv) => (flat ? 1 : 1.14 - 0.34 * Math.abs(tv)));
  const opacity = useTransform(t, (tv) => (flat ? 1 : 1 - 0.55 * Math.abs(tv)));

  return (
    <motion.li
      id={id}
      role="option"
      aria-selected={selected}
      className="h-[var(--wheel-item)] flex items-center justify-center [scroll-snap-align:center] text-[0.9375rem] font-medium tabular-nums text-[#111] cursor-pointer select-none"
      style={{ rotateX, scale, opacity }}
      onClick={onClick}
    >
      {label}
    </motion.li>
  );
}
```
