{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "inertial-wheel-list",
  "type": "registry:component",
  "title": "Inertial Wheel List",
  "description": "An iOS picker drum built on native scroll-snap, with selection derived from scrollTop.",
  "registryDependencies": [
    "https://lab.moumen.dev/r/lab-theme.json"
  ],
  "files": [
    {
      "path": "registry/lab/inertial-wheel-list/inertial-wheel-list.tsx",
      "type": "registry:component",
      "target": "@components/lab/inertial-wheel-list.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type CSSProperties,\n  type KeyboardEvent,\n} from \"react\";\nimport { motion, useReducedMotion, useScroll, useTransform, type MotionValue } from \"motion/react\";\n\n// Inertial wheel list - the iOS picker drum, rebuilt on one principle: the\n// SCROLL POSITION IS THE STATE. Nothing is scroll-jacked and no library fakes\n// the physics: the scroller is a plain overflow-y list, so the browser (and a\n// thumb on a phone) owns momentum, and `scroll-snap-type: y mandatory` +\n// `scroll-snap-align: center` land every fling on an item. The selection is\n// DERIVED from scrollTop - round(scrollTop / itemHeight) - never stored beside\n// it, so the two can't disagree.\n//\n// The drum look is paint, not layout: motion's `useScroll` tracks the scroller\n// and every item derives `rotateX(±38° · t) scale(1.14 → 0.80)` and opacity from\n// its distance to the viewport's centre via `useTransform` - motion values\n// update outside the React render loop, GPU-composited, no per-frame setState.\n// The edge fade is a `mask-image` gradient on the scroller, so items dissolve at\n// the rim instead of clipping.\n//\n// Settling: `scrollend` fires when the snap lands, but not in every engine, so\n// a 140ms quiet-timer fallback commits the same selection. Keyboard follows the\n// listbox pattern - the scroller is the single tab stop, arrows/Home/End scroll\n// to the neighbour (which updates selection because selection IS scroll) and\n// aria-activedescendant tracks the centre item. Honours prefers-reduced-motion:\n// transforms stay flat, programmatic scrolls jump.\n//\n// Geometry lives in three CSS custom properties on the root (--wheel-w/-h/-item)\n// so the same wheel is fluid on a phone. Fully Tailwind; animation via motion/react.\n\nconst WHEEL_VARS = {\n  \"--wheel-w\": \"14rem\",\n  \"--wheel-h\": \"12.5rem\",\n  \"--wheel-item\": \"2.5rem\",\n} as CSSProperties;\n\nexport interface WheelState {\n  value: string;\n  index: number;\n  count: number;\n  settled: boolean;\n}\n\ninterface Metrics {\n  itemH: number;\n  centers: number[];\n  half: number;\n}\n\n// Fallback geometry (16px root): item 40px, viewport 200px - used until the\n// first real measure lands.\nconst fallbackMetrics = (count: number): Metrics => ({\n  itemH: 40,\n  centers: Array.from({ length: count }, (_, i) => 80 + 40 * i + 20),\n  half: 100,\n});\n\nexport default function WheelList({\n  items,\n  label = \"Pick a value\",\n  initialIndex = 0,\n  drum = true,\n  inspect = false,\n  onStateChange,\n}: {\n  items: string[];\n  label?: string;\n  initialIndex?: number;\n  drum?: boolean;\n  inspect?: boolean;\n  onStateChange?: (state: WheelState) => void;\n}) {\n  const scrollerRef = useRef<HTMLDivElement>(null);\n  const settleTimer = useRef(0);\n  const idBase = useId();\n  const reduced = useReducedMotion();\n\n  const [metrics, setMetrics] = useState<Metrics>(() => fallbackMetrics(items.length));\n  const [index, setIndex] = useState(initialIndex);\n  const [settled, setSettled] = useState(true);\n  const indexRef = useRef(initialIndex);\n  const metricsRef = useRef(metrics);\n  metricsRef.current = metrics;\n\n  const { scrollY } = useScroll({ container: scrollerRef });\n\n  const clampIndex = useCallback(\n    (i: number) => Math.min(Math.max(i, 0), items.length - 1),\n    [items.length],\n  );\n\n  function handleScroll() {\n    // Selection derives from the scroll on every frame; it commits when the\n    // snap lands (scrollend where the engine has it, the quiet-timer elsewhere).\n    const scroller = scrollerRef.current;\n    if (scroller) {\n      const next = clampIndex(Math.round(scroller.scrollTop / metricsRef.current.itemH));\n      if (next !== indexRef.current) {\n        indexRef.current = next;\n        setIndex(next);\n      }\n    }\n    setSettled(false);\n    window.clearTimeout(settleTimer.current);\n    settleTimer.current = window.setTimeout(() => setSettled(true), 140);\n  }\n\n  function handleScrollEnd() {\n    window.clearTimeout(settleTimer.current);\n    setSettled(true);\n  }\n\n  function scrollToIndex(i: number, smooth = true) {\n    const scroller = scrollerRef.current;\n    if (!scroller) return;\n    scroller.scrollTo({\n      top: clampIndex(i) * metricsRef.current.itemH,\n      behavior: smooth && !reduced ? \"smooth\" : \"auto\",\n    });\n  }\n\n  function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {\n    const steps: Record<string, number> = { ArrowUp: -1, ArrowDown: 1, PageUp: -5, PageDown: 5 };\n    let target: number;\n    if (event.key in steps) target = indexRef.current + steps[event.key];\n    else if (event.key === \"Home\") target = 0;\n    else if (event.key === \"End\") target = items.length - 1;\n    else return;\n    event.preventDefault();\n    scrollToIndex(target);\n  }\n\n  // Measure once (and on resize / new items): item height, each centre, and the\n  // half-viewport - cached so the motion transforms never read layout.\n  useLayoutEffect(() => {\n    const scroller = scrollerRef.current;\n    if (!scroller) return undefined;\n    const measure = () => {\n      const options = scroller.querySelectorAll<HTMLElement>('[role=\"option\"]');\n      if (!options.length) return;\n      setMetrics({\n        itemH: options[0].offsetHeight,\n        centers: Array.from(options, (el) => el.offsetTop + el.offsetHeight / 2),\n        half: scroller.clientHeight / 2,\n      });\n    };\n    measure();\n    const observer = typeof ResizeObserver !== \"undefined\" ? new ResizeObserver(measure) : null;\n    observer?.observe(scroller);\n    return () => observer?.disconnect();\n  }, [items]);\n\n  // Land on the initial value before first paint - no snap animation on load.\n  useLayoutEffect(() => {\n    const scroller = scrollerRef.current;\n    if (scroller) scroller.scrollTop = clampIndex(initialIndex) * metricsRef.current.itemH;\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  useEffect(() => () => window.clearTimeout(settleTimer.current), []);\n\n  useEffect(() => {\n    onStateChange?.({ value: items[index], index, count: items.length, settled });\n  }, [items, index, settled, onStateChange]);\n\n  const optionId = (i: number) => `${idBase}-opt-${i}`;\n  const value = items[index];\n\n  return (\n    <div className=\"relative w-full max-w-[var(--wheel-w)]\" style={WHEEL_VARS}>\n      <div className=\"relative p-2 rounded-[1.25rem] bg-card shadow-border\">\n        {/* The selection lens: a static bar the centred item scrolls through. */}\n        <span\n          className=\"absolute left-2 right-2 top-1/2 h-[var(--wheel-item)] -translate-y-1/2 rounded-xl bg-muted pointer-events-none\"\n          aria-hidden=\"true\"\n        />\n        <div\n          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-ring\"\n          ref={scrollerRef}\n          role=\"listbox\"\n          aria-label={label}\n          aria-activedescendant={optionId(index)}\n          tabIndex={0}\n          data-drum={drum ? \"true\" : \"false\"}\n          onScroll={handleScroll}\n          onScrollEnd={handleScrollEnd}\n          onKeyDown={handleKeyDown}\n        >\n          <ul className=\"[padding-block:calc((var(--wheel-h)-var(--wheel-item))/2)]\">\n            {items.map((item, i) => (\n              <Option\n                key={item}\n                id={optionId(i)}\n                label={item}\n                selected={i === index}\n                scrollY={scrollY}\n                center={metrics.centers[i] ?? fallbackMetrics(items.length).centers[i]}\n                half={metrics.half}\n                drum={drum}\n                flat={Boolean(reduced)}\n                onClick={() => scrollToIndex(i)}\n              />\n            ))}\n          </ul>\n        </div>\n        {inspect && (\n          <>\n            <span\n              className=\"absolute left-1 right-1 top-1/2 z-[5] border-t-[1.5px] border-dashed border-[#ef4444] pointer-events-none\"\n              aria-hidden=\"true\"\n            />\n            <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]\">\n              {drum ? \"rotateX(38° · t) · \" : \"\"}scale(1.14 − 0.34|t|)\n            </span>\n            <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]\">\n              index = round(scrollTop / {metrics.itemH}px) · snap mandatory\n            </span>\n          </>\n        )}\n      </div>\n\n      <p className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {settled ? `Selected ${value}` : \"Scrolling\"}\n      </p>\n    </div>\n  );\n}\n\n// One drum row. `t` is the item's signed distance from the viewport centre in\n// half-viewport units - the centre item is biggest (scale 1.14) and the rim\n// dissolves, by continuous function rather than a styled selected class. All\n// four styles are motion values derived from the scroll: no React re-render,\n// no layout read, GPU-composited.\nfunction Option({\n  id,\n  label,\n  selected,\n  scrollY,\n  center,\n  half,\n  drum,\n  flat,\n  onClick,\n}: {\n  id: string;\n  label: string;\n  selected: boolean;\n  scrollY: MotionValue<number>;\n  center: number;\n  half: number;\n  drum: boolean;\n  flat: boolean;\n  onClick: () => void;\n}) {\n  const t = useTransform(scrollY, (v) => Math.max(-1, Math.min(1, (center - (v + half)) / half)));\n  const rotateX = useTransform(t, (tv) => (flat || !drum ? 0 : -38 * tv));\n  const scale = useTransform(t, (tv) => (flat ? 1 : 1.14 - 0.34 * Math.abs(tv)));\n  const opacity = useTransform(t, (tv) => (flat ? 1 : 1 - 0.55 * Math.abs(tv)));\n\n  return (\n    <motion.li\n      id={id}\n      role=\"option\"\n      aria-selected={selected}\n      className=\"h-[var(--wheel-item)] flex items-center justify-center [scroll-snap-align:center] text-[0.9375rem] font-medium tabular-nums text-foreground cursor-pointer select-none\"\n      style={{ rotateX, scale, opacity }}\n      onClick={onClick}\n    >\n      {label}\n    </motion.li>\n  );\n}\n"
    }
  ],
  "dependencies": [
    "motion"
  ]
}
