{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"wheel-picker","type":"registry:component","title":"Wheel Picker","description":"iOS-style picker wheel: a 3D drum on native momentum scroll that snaps to the nearest notch, with wheel, drag and keyboard control. Composes side by side for date and time pickers, reduced-motion safe.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/wheel-picker.tsx","type":"registry:component","target":"@components/motion/wheel-picker.tsx","content":"\"use client\";\n// beui.dev/components/motion/wheel-picker\n\nimport { useReducedMotion } from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type PointerEvent,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type WheelPickerOption = string | { label: string; value: string };\n\nexport interface WheelPickerProps {\n  options: WheelPickerOption[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /** Rows visible through the window, odd. More = flatter curve. Default 5. */\n  visibleCount?: number;\n  /** Row height in px. Default 36. */\n  itemHeight?: number;\n  disabled?: boolean;\n  className?: string;\n  \"aria-label\"?: string;\n}\n\nconst DEG = Math.PI / 180;\n// Physics constants, tuned for an iOS-like flick rather than reused from the\n// shared spring tokens: the wheel coasts in whole-row units and springs to an\n// integer detent, which a layout spring can't express cleanly.\nconst DECELERATION = 0.00042; // rows per ms², how fast a flick bleeds off (lower = freer)\nconst MAX_VELOCITY = 0.18; // rows per ms, caps a hard fling\nconst VELOCITY_WINDOW = 90; // ms of recent drag to average a release velocity over\nconst WHEEL_SENS = 0.012; // rows per pixel of wheel delta\nconst WHEEL_SETTLE = 110; // ms of wheel idle before snapping to a row\nconst easeOutCubic = (p: number) => 1 - (1 - p) ** 3;\n// Overshoots the target by a few percent then settles — the little spring\n// bounce as a row snaps home. `BACK` sets how far it drifts past.\nconst BACK = 1.35;\nconst easeOutBack = (p: number) =>\n  1 + (BACK + 1) * (p - 1) ** 3 + BACK * (p - 1) ** 2;\n\nconst clamp = (v: number, lo: number, hi: number) =>\n  Math.max(lo, Math.min(v, hi));\n\nfunction optionValue(option: WheelPickerOption) {\n  return typeof option === \"string\" ? option : option.value;\n}\nfunction optionLabel(option: WheelPickerOption) {\n  return typeof option === \"string\" ? option : option.label;\n}\n\nexport function WheelPicker({\n  options,\n  value,\n  defaultValue,\n  onValueChange,\n  visibleCount = 5,\n  itemHeight = 36,\n  disabled = false,\n  className,\n  \"aria-label\": ariaLabel,\n}: WheelPickerProps) {\n  const reduce = useReducedMotion() ?? false;\n  const controlled = value !== undefined;\n  const last = options.length - 1;\n\n  const indexOf = useCallback(\n    (v: string | undefined) => {\n      const i = options.findIndex((o) => optionValue(o) === v);\n      return i < 0 ? 0 : i;\n    },\n    [options],\n  );\n\n  const [internal, setInternal] = useState(() => defaultValue ?? value);\n  const currentValue = controlled ? value : internal;\n  const [grabbing, setGrabbing] = useState(false);\n\n  // Cylinder geometry. Each row spans `itemAngle`; `radius` seats the rows on\n  // the drum; rows past `hideBeyond` sit behind the horizon and are dropped.\n  const { itemAngle, radius, height, hideBeyond } = useMemo(() => {\n    const rowsEachSide = Math.max(1, Math.floor(visibleCount / 2));\n    const cutoff = rowsEachSide + 1;\n    const angle = 90 / cutoff;\n    const r = itemHeight / Math.tan(angle * DEG);\n    return {\n      itemAngle: angle,\n      radius: r,\n      hideBeyond: cutoff,\n      height: Math.round(\n        2 * r * Math.sin(rowsEachSide * angle * DEG) + itemHeight,\n      ),\n    };\n  }, [visibleCount, itemHeight]);\n\n  const container = useRef<HTMLDivElement>(null);\n  const drumRef = useRef<HTMLUListElement>(null);\n  const bandRef = useRef<HTMLUListElement>(null);\n  // Scroll position measured in rows (a float index). One source of truth for\n  // both layers: the drum rotates by `itemAngle·scroll`, the crisp band slides\n  // by `itemHeight·scroll`.\n  const scroll = useRef(indexOf(currentValue));\n  const raf = useRef(0);\n  const emitted = useRef(currentValue);\n\n  const paint = useCallback(\n    (s: number) => {\n      const drum = drumRef.current;\n      const band = bandRef.current;\n      if (drum) {\n        drum.style.transform = `translateZ(${-radius}px) rotateX(${itemAngle * s}deg)`;\n        for (const node of Array.from(drum.children)) {\n          const li = node as HTMLLIElement;\n          const i = Number(li.dataset.index);\n          const want = Math.abs(i - s) > hideBeyond ? \"hidden\" : \"visible\";\n          // Write only on change — an unconditional write every frame thrashes\n          // style recalc and is what made the drag feel draggy on mobile.\n          if (li.style.visibility !== want) li.style.visibility = want;\n        }\n      }\n      // The band is the SAME drum, clipped to the centre row — driven by the\n      // identical transform so the crisp copy sits exactly on the dimmed one,\n      // with no parallax ghost as rows cross the window. It needs the same\n      // horizon cull, or the row on the back of the drum bleeds into the front.\n      if (band) {\n        band.style.transform = `translateZ(${-radius}px) rotateX(${itemAngle * s}deg)`;\n        for (const node of Array.from(band.children)) {\n          const li = node as HTMLLIElement;\n          const i = Number(li.dataset.index);\n          const want = Math.abs(i - s) > hideBeyond ? \"hidden\" : \"visible\";\n          if (li.style.visibility !== want) li.style.visibility = want;\n        }\n      }\n    },\n    [radius, itemAngle, hideBeyond],\n  );\n\n  const emit = useCallback(\n    (i: number) => {\n      const v = optionValue(options[clamp(i, 0, last)]);\n      if (v === emitted.current) return;\n      emitted.current = v;\n      if (!controlled) setInternal(v);\n      onValueChange?.(v);\n    },\n    [options, last, controlled, onValueChange],\n  );\n\n  const stop = useCallback(() => cancelAnimationFrame(raf.current), []);\n\n  // Ease `scroll` from where it is to an integer detent over `duration`. The\n  // easing may overshoot (spring bounce) before it resolves exactly on `to`.\n  const glide = useCallback(\n    (\n      to: number,\n      duration: number,\n      ease: (p: number) => number = easeOutCubic,\n    ) => {\n      stop();\n      const from = scroll.current;\n      const dist = to - from;\n      if (!dist || duration <= 0) {\n        scroll.current = to;\n        paint(to);\n        emit(to);\n        return;\n      }\n      const start = performance.now();\n      const tick = (now: number) => {\n        const p = (now - start) / duration;\n        if (p >= 1) {\n          scroll.current = to;\n          paint(to);\n          emit(to);\n          return;\n        }\n        scroll.current = from + dist * ease(p);\n        paint(scroll.current);\n        raf.current = requestAnimationFrame(tick);\n      };\n      raf.current = requestAnimationFrame(tick);\n    },\n    [stop, paint, emit],\n  );\n\n  // Project where a flick of `velocity` (rows/ms) coasts to, snap to a row.\n  const fling = useCallback(\n    (velocity: number) => {\n      const from = scroll.current;\n      if (from < 0 || from > last) {\n        glide(clamp(Math.round(from), 0, last), 260); // rubber-band back\n        return;\n      }\n      const dir = Math.sign(velocity);\n      const coast = ((velocity * velocity) / (2 * DECELERATION)) * dir;\n      const to = clamp(Math.round(from + coast), 0, last);\n      // Long, spring-tipped settle so a hard flick reads as free momentum\n      // coasting to rest with a gentle bounce, never a clipped stop.\n      const duration = clamp(\n        Math.sqrt(Math.abs(to - from)) * 300 + 240,\n        280,\n        1700,\n      );\n      glide(to, duration, easeOutBack);\n    },\n    [glide, last],\n  );\n\n  const step = useCallback(\n    (by: number) =>\n      glide(clamp(Math.round(scroll.current) + by, 0, last), 300, easeOutBack),\n    [glide, last],\n  );\n\n  // Drag: track recent points for a release velocity; rubber-band past the ends.\n  const drag = useRef<{\n    y: number;\n    scroll: number;\n    pts: [number, number][];\n  } | null>(null);\n  // Coalesce touch/pointer moves to one paint per animation frame — raw move\n  // events fire several times per frame (and off-frame) on high-refresh\n  // screens, and painting each one is what made the drag feel choppy.\n  const dragFrame = useRef(0);\n  const latestY = useRef(0);\n  // Shared drag core, driven by a Y coordinate from either a mouse pointer or a\n  // native touch. Touch is bound with non-passive listeners in the effect below\n  // so the move can preventDefault the page scroll — React's synthetic touch\n  // events are passive and can't, which is why finger-drag did nothing on\n  // mobile.\n  const beginDrag = useCallback(\n    (y: number) => {\n      stop();\n      setGrabbing(true);\n      drag.current = {\n        y,\n        scroll: scroll.current,\n        pts: [[y, performance.now()]],\n      };\n    },\n    [stop],\n  );\n  const moveDrag = useCallback(\n    (y: number) => {\n      const d = drag.current;\n      if (!d) return;\n      // Record every sample for an accurate release velocity, but only render\n      // the newest position once per frame.\n      latestY.current = y;\n      d.pts.push([y, performance.now()]);\n      if (d.pts.length > 8) d.pts.shift();\n      if (dragFrame.current) return;\n      dragFrame.current = requestAnimationFrame(() => {\n        dragFrame.current = 0;\n        const dd = drag.current;\n        if (!dd) return;\n        let next = dd.scroll + (dd.y - latestY.current) / itemHeight;\n        if (next < 0) next *= 0.3;\n        else if (next > last) next = last + (next - last) * 0.3;\n        scroll.current = next;\n        paint(next);\n        emit(Math.round(clamp(next, 0, last)));\n      });\n    },\n    [itemHeight, last, paint, emit],\n  );\n  const endDrag = useCallback(() => {\n    const d = drag.current;\n    if (!d) return;\n    if (dragFrame.current) {\n      cancelAnimationFrame(dragFrame.current);\n      dragFrame.current = 0;\n    }\n    drag.current = null;\n    setGrabbing(false);\n    // Average velocity over the last `VELOCITY_WINDOW` ms of movement rather\n    // than the final two samples — a single noisy frame otherwise makes an\n    // even flick feel like it caught or slipped.\n    const pts = d.pts;\n    let v = 0;\n    if (pts.length > 1) {\n      const latest = pts[pts.length - 1];\n      let ref = pts[0];\n      for (const p of pts) {\n        if (latest[1] - p[1] <= VELOCITY_WINDOW) {\n          ref = p;\n          break;\n        }\n      }\n      const dt = latest[1] - ref[1];\n      if (dt > 0) {\n        const raw = (ref[0] - latest[0]) / itemHeight / dt;\n        v = clamp(raw, -MAX_VELOCITY, MAX_VELOCITY);\n      }\n    }\n    fling(v);\n  }, [itemHeight, fling]);\n\n  // Mouse / pen only — touch runs through the native listeners in the effect.\n  const onPointerDown = useCallback(\n    (event: PointerEvent<HTMLDivElement>) => {\n      if (disabled || reduce || event.pointerType === \"touch\") return;\n      event.currentTarget.setPointerCapture(event.pointerId);\n      beginDrag(event.clientY);\n    },\n    [disabled, reduce, beginDrag],\n  );\n  const onPointerMove = useCallback(\n    (event: PointerEvent<HTMLDivElement>) => {\n      if (event.pointerType === \"touch\") return;\n      moveDrag(event.clientY);\n    },\n    [moveDrag],\n  );\n  const onPointerUp = useCallback(\n    (event: PointerEvent<HTMLDivElement>) => {\n      if (event.pointerType === \"touch\") return;\n      event.currentTarget.releasePointerCapture?.(event.pointerId);\n      endDrag();\n    },\n    [endDrag],\n  );\n\n  // Wheel drives `scroll` continuously — like a drag — then snaps once it goes\n  // idle. Firing a fresh eased step per notch instead stacks overlapping\n  // animations that keep interrupting each other, which read as lag.\n  const wheelSnap = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const onWheel = useCallback(\n    (event: globalThis.WheelEvent) => {\n      // Native (non-passive) so preventDefault actually stops the page from\n      // scrolling behind the wheel — React's synthetic wheel listener is\n      // passive, so a handler on the element cannot cancel the scroll.\n      if (disabled || reduce) return;\n      event.preventDefault();\n      stop();\n      const px = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;\n      const next = clamp(scroll.current + px * WHEEL_SENS, 0, last);\n      scroll.current = next;\n      paint(next);\n      emit(Math.round(next));\n      if (wheelSnap.current) clearTimeout(wheelSnap.current);\n      wheelSnap.current = setTimeout(() => {\n        glide(clamp(Math.round(scroll.current), 0, last), 240, easeOutBack);\n      }, WHEEL_SETTLE);\n    },\n    [disabled, reduce, last, paint, emit, stop, glide],\n  );\n\n  const onKeyDown = useCallback(\n    (event: KeyboardEvent<HTMLDivElement>) => {\n      if (disabled) return;\n      const at = Math.round(scroll.current);\n      const map: Record<string, number> = {\n        ArrowUp: -1,\n        ArrowDown: 1,\n        Home: -at,\n        End: last - at,\n      };\n      if (event.key in map) {\n        event.preventDefault();\n        step(map[event.key]);\n      }\n    },\n    [disabled, last, step],\n  );\n\n  // Paint the starting frame, and follow controlled/value changes from outside\n  // unless a gesture is mid-flight.\n  useEffect(() => {\n    if (drag.current) return;\n    const target = indexOf(currentValue);\n    emitted.current = currentValue;\n    if (Math.abs(Math.round(scroll.current) - target) < 0.001) {\n      paint(scroll.current);\n      return;\n    }\n    glide(target, 260);\n  }, [currentValue, indexOf, paint, glide]);\n\n  useEffect(\n    () => () => {\n      cancelAnimationFrame(raf.current);\n      cancelAnimationFrame(dragFrame.current);\n      if (wheelSnap.current) clearTimeout(wheelSnap.current);\n    },\n    [],\n  );\n\n  // Native touch + wheel listeners, bound non-passively so touchmove and wheel\n  // can block the page from scrolling while the wheel spins. React's synthetic\n  // touch/wheel handlers are passive, so preventDefault there is a no-op and the\n  // gesture scrolls the page instead of driving the drum.\n  useEffect(() => {\n    const el = container.current;\n    if (!el || reduce || disabled) return;\n    const onStart = (e: TouchEvent) => {\n      const t = e.touches[0];\n      if (t) beginDrag(t.clientY);\n    };\n    const onMove = (e: TouchEvent) => {\n      const t = e.touches[0];\n      if (!t || !drag.current) return;\n      e.preventDefault();\n      moveDrag(t.clientY);\n    };\n    const onEnd = () => endDrag();\n    el.addEventListener(\"touchstart\", onStart, { passive: true });\n    el.addEventListener(\"touchmove\", onMove, { passive: false });\n    el.addEventListener(\"touchend\", onEnd);\n    el.addEventListener(\"touchcancel\", onEnd);\n    el.addEventListener(\"wheel\", onWheel, { passive: false });\n    return () => {\n      el.removeEventListener(\"touchstart\", onStart);\n      el.removeEventListener(\"touchmove\", onMove);\n      el.removeEventListener(\"touchend\", onEnd);\n      el.removeEventListener(\"touchcancel\", onEnd);\n      el.removeEventListener(\"wheel\", onWheel);\n    };\n  }, [reduce, disabled, beginDrag, moveDrag, endDrag, onWheel]);\n\n  const maskFade =\n    \"[mask-image:linear-gradient(to_bottom,transparent,#000_22%,#000_78%,transparent)]\";\n\n  if (reduce) {\n    const pad = (height - itemHeight) / 2;\n    return (\n      <div\n        className={cn(\n          \"relative overflow-hidden rounded-2xl border border-border bg-card\",\n          disabled && \"pointer-events-none opacity-50\",\n          className,\n        )}\n        style={{ height }}\n      >\n        <div\n          className=\"pointer-events-none absolute inset-x-0 top-1/2 z-10 -translate-y-1/2 border-border border-y bg-foreground/[0.04]\"\n          style={{ height: itemHeight }}\n        />\n        <ul\n          className={cn(\n            \"h-full snap-y snap-mandatory overflow-y-auto scroll-smooth [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\",\n            maskFade,\n          )}\n          style={{ paddingTop: pad, paddingBottom: pad }}\n        >\n          {options.map((option) => {\n            const v = optionValue(option);\n            return (\n              <li key={v} className=\"snap-center\">\n                <button\n                  type=\"button\"\n                  disabled={disabled}\n                  onClick={() => emit(options.indexOf(option))}\n                  className={cn(\n                    \"flex w-full items-center justify-center font-medium\",\n                    v === currentValue\n                      ? \"text-foreground\"\n                      : \"text-muted-foreground\",\n                  )}\n                  style={{ height: itemHeight }}\n                >\n                  {optionLabel(option)}\n                </button>\n              </li>\n            );\n          })}\n        </ul>\n      </div>\n    );\n  }\n\n  return (\n    <div\n      ref={container}\n      role=\"listbox\"\n      aria-label={ariaLabel}\n      tabIndex={disabled ? -1 : 0}\n      onKeyDown={onKeyDown}\n      onPointerDown={onPointerDown}\n      onPointerMove={onPointerMove}\n      onPointerUp={onPointerUp}\n      onPointerCancel={onPointerUp}\n      className={cn(\n        \"relative touch-none select-none overflow-hidden rounded-2xl border border-border bg-card outline-none focus-visible:ring-2 focus-visible:ring-foreground/20\",\n        grabbing ? \"cursor-grabbing\" : \"cursor-grab\",\n        disabled && \"pointer-events-none opacity-50\",\n        maskFade,\n        className,\n      )}\n      style={{ height, perspective: 1000 }}\n    >\n      {/* Curved drum of dimmed rows. */}\n      <ul\n        ref={drumRef}\n        aria-hidden\n        className=\"absolute inset-x-0 top-1/2 m-0 h-0 list-none p-0 [backface-visibility:hidden] [transform-style:preserve-3d] [will-change:transform]\"\n      >\n        {options.map((option, i) => (\n          <li\n            key={optionValue(option)}\n            data-index={i}\n            className=\"absolute inset-x-0 flex items-center justify-center font-medium text-muted-foreground\"\n            style={{\n              top: -itemHeight / 2,\n              height: itemHeight,\n              transform: `rotateX(${-itemAngle * i}deg) translateZ(${radius}px)`,\n            }}\n          >\n            {optionLabel(option)}\n          </li>\n        ))}\n      </ul>\n\n      {/* Center band: the very same drum, clipped to one row and drawn crisp.\n          Its own perspective, centred on the container middle, matches the main\n          drum's projection so the two copies register exactly. */}\n      <div\n        className=\"pointer-events-none absolute inset-x-0 top-1/2 z-10 -translate-y-1/2 overflow-hidden rounded-md bg-foreground/[0.04]\"\n        style={{ height: itemHeight, perspective: 1000 }}\n      >\n        <ul\n          ref={bandRef}\n          aria-hidden\n          className=\"absolute inset-x-0 top-1/2 m-0 h-0 list-none p-0 [backface-visibility:hidden] [transform-style:preserve-3d] [will-change:transform]\"\n        >\n          {options.map((option, i) => (\n            <li\n              key={optionValue(option)}\n              data-index={i}\n              className=\"absolute inset-x-0 flex items-center justify-center font-medium text-foreground\"\n              style={{\n                top: -itemHeight / 2,\n                height: itemHeight,\n                transform: `rotateX(${-itemAngle * i}deg) translateZ(${radius}px)`,\n              }}\n            >\n              {optionLabel(option)}\n            </li>\n          ))}\n        </ul>\n      </div>\n    </div>\n  );\n}\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}