{
  "name": "wheel-picker",
  "type": "registry:component",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Wheel Picker\n * @version: 1.0.0\n * @date: 2026-10-13\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport {\n  type AnimationPlaybackControls,\n  animate,\n  type MotionValue,\n  motion,\n  useMotionValue,\n  useMotionValueEvent,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type PointerEvent,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface WheelOption {\n  value: string;\n  label: string;\n}\n\nexport interface WheelColumn {\n  id: string;\n  /** Accessible name of the column, e.g. \"Hours\". */\n  label: string;\n  options: WheelOption[];\n  /** Wrap around from the last option to the first. */\n  loop?: boolean;\n  /** Tailwind width class for the column. */\n  className?: string;\n}\n\nconst range = (count: number, format: (i: number) => string) =>\n  Array.from({ length: count }, (_, i) => ({\n    value: String(i),\n    label: format(i),\n  }));\n\nconst DEFAULT_COLUMNS: WheelColumn[] = [\n  {\n    id: \"hour\",\n    label: \"Hours\",\n    loop: true,\n    className: \"w-16\",\n    options: range(12, (i) => String(i + 1)),\n  },\n  {\n    id: \"minute\",\n    label: \"Minutes\",\n    loop: true,\n    className: \"w-16\",\n    options: range(60, (i) => String(i).padStart(2, \"0\")),\n  },\n  {\n    id: \"period\",\n    label: \"AM or PM\",\n    className: \"w-16\",\n    options: [\n      { value: \"am\", label: \"AM\" },\n      { value: \"pm\", label: \"PM\" },\n    ],\n  },\n];\n\nconst DEFAULT_VALUE: Record<string, string> = {\n  hour: \"8\",\n  minute: \"41\",\n  period: \"am\",\n};\n\nconst ITEM_HEIGHT = 36;\n// Each row sits this many degrees further round the drum.\nconst ITEM_ANGLE = 20;\n// Drum radius that makes neighbouring rows touch exactly.\n// Rounded so server and browser trig can't disagree in the markup.\nconst RADIUS =\n  Math.round(\n    (ITEM_HEIGHT / 2 / Math.tan((ITEM_ANGLE / 2) * (Math.PI / 180))) * 100\n  ) / 100;\n// Rows past ±90° face away; render a few either side of that and no more.\nconst WINDOW = Math.ceil(90 / ITEM_ANGLE);\nconst VIEW_HEIGHT = Math.round(RADIUS * 2) + ITEM_HEIGHT / 2;\n// A non-looping wheel can be dragged this many rows past either end.\nconst MAX_OVERSCROLL = 1.2;\nconst VELOCITY_WINDOW_MS = 80;\nconst TAP_SLOP = 4;\n// Seconds of travel a flick is worth: release speed × COAST = rows coasted.\nconst COAST = 0.32;\n\nconst mod = (n: number, m: number) => ((n % m) + m) % m;\n\nfunction damp(distance: number, max: number) {\n  return max * (1 - Math.exp(-distance / max));\n}\n\nfunction hapticTick() {\n  if (typeof navigator !== \"undefined\" && \"vibrate\" in navigator) {\n    navigator.vibrate(1);\n  }\n}\n\nexport interface WheelPickerProps {\n  columns?: WheelColumn[];\n  /** Controlled selection, keyed by column id. */\n  value?: Record<string, string>;\n  /** Initial selection when uncontrolled. */\n  defaultValue?: Record<string, string>;\n  /** Called once a wheel comes to rest on a new option. */\n  onValueChange?: (value: Record<string, string>) => void;\n  /** Accessible name for the whole picker. */\n  label?: string;\n  className?: string;\n}\n\n/**\n * An iOS-style drum picker. Each column is a real 3D cylinder: rows are\n * placed round it with `rotateX() translateZ()` and the whole drum turns\n * with a single transform, so a frame costs one style write per column.\n *\n * The drum is drawn twice. The bottom copy is muted; the top copy is in full\n * color and clipped to the selection band. Text turns dark exactly as it\n * crosses the band's edge, the way the native picker does. Hairline ticks\n * mark the band's ends.\n */\nexport default function WheelPicker({\n  columns = DEFAULT_COLUMNS,\n  value: valueProp,\n  defaultValue = DEFAULT_VALUE,\n  onValueChange,\n  label = \"Time\",\n  className,\n}: WheelPickerProps) {\n  const [internal, setInternal] = useState(defaultValue);\n  const value = valueProp ?? internal;\n\n  const select = (columnId: string, optionValue: string) => {\n    if (value[columnId] === optionValue) {\n      return;\n    }\n    const next = { ...value, [columnId]: optionValue };\n    if (valueProp === undefined) {\n      setInternal(next);\n    }\n    onValueChange?.(next);\n  };\n\n  return (\n    // biome-ignore lint/a11y/useSemanticElements: a fieldset would add a border and legend layout the picker doesn't want.\n    <div\n      aria-label={label}\n      className={cn(\n        \"relative flex select-none justify-center rounded-2xl border border-zinc-200 bg-white px-3 dark:border-zinc-800 dark:bg-zinc-950\",\n        className\n      )}\n      role=\"group\"\n    >\n      {/* The selection band, shared by every column. */}\n      <div\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-x-2 top-1/2 -translate-y-1/2 rounded-lg bg-zinc-100 dark:bg-zinc-800/70\"\n        style={{ height: ITEM_HEIGHT }}\n      />\n      {/* Hairline ticks either side of the band, like a ruler's pointer. */}\n      <span\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute top-1/2 left-0.5 h-px w-1 -translate-y-1/2 bg-zinc-400 dark:bg-zinc-500\"\n      />\n      <span\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute top-1/2 right-0.5 h-px w-1 -translate-y-1/2 bg-zinc-400 dark:bg-zinc-500\"\n      />\n      {columns.map((column) => (\n        <WheelColumnView\n          column={column}\n          key={column.id}\n          onSelect={(optionValue) => select(column.id, optionValue)}\n          selected={value[column.id]}\n        />\n      ))}\n    </div>\n  );\n}\n\nfunction WheelColumnView({\n  column,\n  selected,\n  onSelect,\n}: {\n  column: WheelColumn;\n  selected: string | undefined;\n  onSelect: (value: string) => void;\n}) {\n  const { options, loop = false } = column;\n  const count = options.length;\n  const selectedIndex = Math.max(\n    options.findIndex((option) => option.value === selected),\n    0\n  );\n  const reduceMotion = useReducedMotion();\n\n  // Position on the drum in rows. A looping wheel uses an unbounded\n  // \"virtual\" index so it can spin forever; the option is index mod count.\n  const position = useMotionValue(selectedIndex);\n  const [center, setCenter] = useState(selectedIndex);\n  const animation = useRef<AnimationPlaybackControls | null>(null);\n  const pointerType = useRef(\"mouse\");\n  const drag = useRef<{\n    id: number;\n    startY: number;\n    startPosition: number;\n    moved: boolean;\n    samples: { t: number; v: number }[];\n  } | null>(null);\n  const columnRef = useRef<HTMLDivElement>(null);\n\n  const drum = useTransform(\n    position,\n    (p) => `translateZ(${-RADIUS}px) rotateX(${p * ITEM_ANGLE}deg)`\n  );\n\n  const optionAt = (index: number) => options[mod(index, count)];\n\n  useMotionValueEvent(position, \"change\", (p) => {\n    const rounded = Math.round(p);\n    if (rounded !== center) {\n      setCenter(rounded);\n      if (pointerType.current === \"touch\") {\n        hapticTick();\n      }\n    }\n  });\n\n  // Follow the selection when it changes from outside (controlled value,\n  // keyboard, another column re-rendering the parent).\n  useEffect(() => {\n    if (drag.current || animation.current) {\n      return;\n    }\n    const current = mod(Math.round(position.get()), count);\n    if (current === selectedIndex) {\n      return;\n    }\n    // Take the short way round on a looping wheel.\n    let delta = selectedIndex - current;\n    if (loop && Math.abs(delta) > count / 2) {\n      delta -= Math.sign(delta) * count;\n    }\n    position.jump(Math.round(position.get()) + delta);\n  }, [selectedIndex, count, loop, position]);\n\n  useEffect(() => () => animation.current?.stop(), []);\n\n  const settle = () => {\n    animation.current = null;\n    onSelect(optionAt(Math.round(position.get())).value);\n  };\n\n  const clampIndex = (index: number) =>\n    loop ? index : Math.min(Math.max(index, 0), count - 1);\n\n  const goTo = (target: number) => {\n    animation.current?.stop();\n    animation.current = null;\n    if (reduceMotion) {\n      position.jump(target);\n      settle();\n      return;\n    }\n    animation.current = animate(position, target, {\n      type: \"spring\",\n      duration: 0.4,\n      bounce: 0.15,\n      restDelta: 0.001,\n      onComplete: settle,\n    });\n  };\n\n  // Past the ends of a non-looping wheel the drum keeps following the\n  // finger, but less and less, then springs back on release.\n  const withOverscroll = (raw: number) => {\n    if (loop) {\n      return raw;\n    }\n    if (raw < 0) {\n      return -damp(-raw, MAX_OVERSCROLL);\n    }\n    if (raw > count - 1) {\n      return count - 1 + damp(raw - (count - 1), MAX_OVERSCROLL);\n    }\n    return raw;\n  };\n\n  const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {\n    if (drag.current) {\n      return;\n    }\n    if (event.pointerType === \"mouse\" && event.button !== 0) {\n      return;\n    }\n    animation.current?.stop();\n    animation.current = null;\n    pointerType.current = event.pointerType;\n    event.currentTarget.setPointerCapture(event.pointerId);\n    drag.current = {\n      id: event.pointerId,\n      startY: event.clientY,\n      startPosition: position.get(),\n      moved: false,\n      samples: [{ t: event.timeStamp, v: position.get() }],\n    };\n  };\n\n  const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {\n    const current = drag.current;\n    if (!current || current.id !== event.pointerId) {\n      return;\n    }\n    const dy = event.clientY - current.startY;\n    if (!current.moved && Math.abs(dy) < TAP_SLOP) {\n      return;\n    }\n    current.moved = true;\n    const raw = current.startPosition - dy / ITEM_HEIGHT;\n    position.set(withOverscroll(raw));\n    current.samples.push({ t: event.timeStamp, v: raw });\n    while (\n      current.samples.length > 2 &&\n      event.timeStamp - current.samples[0].t > VELOCITY_WINDOW_MS\n    ) {\n      current.samples.shift();\n    }\n  };\n\n  const handlePointerUp = (event: PointerEvent<HTMLDivElement>) => {\n    const current = drag.current;\n    if (!current || current.id !== event.pointerId) {\n      return;\n    }\n    drag.current = null;\n\n    if (!current.moved) {\n      // A tap: turn to the row under the finger. asin undoes the drum's\n      // foreshortening, so rows near the edges are hit accurately too.\n      const rect = event.currentTarget.getBoundingClientRect();\n      const offsetY = event.clientY - (rect.top + rect.height / 2);\n      const ratio = Math.max(-1, Math.min(1, offsetY / RADIUS));\n      const rows = Math.round((Math.asin(ratio) * 180) / Math.PI / ITEM_ANGLE);\n      goTo(clampIndex(Math.round(position.get()) + rows));\n      return;\n    }\n\n    const first = current.samples[0];\n    const last = current.samples.at(-1) ?? first;\n    const elapsed = (last.t - first.t) / 1000;\n    // Holding still before letting go is a placement, not a flick.\n    const paused = event.timeStamp - last.t > VELOCITY_WINDOW_MS;\n    const velocity = elapsed > 0 && !paused ? (last.v - first.v) / elapsed : 0;\n    const p = position.get();\n    const outOfBounds = !loop && (p < 0 || p > count - 1);\n\n    if (reduceMotion || outOfBounds || Math.abs(velocity) < 2) {\n      goTo(clampIndex(Math.round(p)));\n      return;\n    }\n    // A flick spins the drum, slows down, and lands exactly on a row. On a\n    // non-looping wheel it bounces off the end if it runs out of options.\n    animation.current?.stop();\n    // Project where the flick would coast to, land that on a whole row,\n    // then spring there carrying the release velocity. Critically damped, so\n    // it decelerates smoothly; past a non-looping end it overshoots slightly\n    // and settles back, which reads as a bounce.\n    const target = clampIndex(Math.round(p + velocity * COAST));\n    animation.current = animate(position, target, {\n      type: \"spring\",\n      velocity,\n      stiffness: 140,\n      damping: 24,\n      restDelta: 0.005,\n      onComplete: settle,\n    });\n  };\n\n  // Keyboard steps are instant: they repeat quickly, and a spin per press\n  // would always be a step behind.\n  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    const index = Math.round(position.get());\n    const moves: Record<string, number> = {\n      ArrowUp: 1,\n      ArrowDown: -1,\n      PageUp: 5,\n      PageDown: -5,\n    };\n    let target: number;\n    if (event.key === \"Home\") {\n      target = index - mod(index, count);\n    } else if (event.key === \"End\") {\n      target = index - mod(index, count) + count - 1;\n    } else if (event.key in moves) {\n      target = clampIndex(index + moves[event.key]);\n    } else {\n      return;\n    }\n    event.preventDefault();\n    animation.current?.stop();\n    pointerType.current = \"keyboard\";\n    position.jump(target);\n    settle();\n  };\n\n  // Wheel and trackpad scrolling turn the drum, then it settles on the\n  // nearest row once the scrolling stops. The listener is attached once and\n  // reads the latest props through a ref, so a re-render mid-scroll (the\n  // drum crossing a row) can't drop the pending settle.\n  const wheel = useRef({ loop, count, goTo, clampIndex });\n  wheel.current = { loop, count, goTo, clampIndex };\n  useEffect(() => {\n    const node = columnRef.current;\n    if (!node) {\n      return;\n    }\n    let timer: number | undefined;\n    const onWheel = (event: WheelEvent) => {\n      event.preventDefault();\n      const latest = wheel.current;\n      animation.current?.stop();\n      animation.current = null;\n      pointerType.current = \"mouse\";\n      const raw = position.get() + (event.deltaY / ITEM_HEIGHT) * 0.4;\n      position.set(\n        latest.loop\n          ? raw\n          : Math.min(Math.max(raw, -0.4), latest.count - 1 + 0.4)\n      );\n      window.clearTimeout(timer);\n      timer = window.setTimeout(() => {\n        const settled = wheel.current;\n        settled.goTo(settled.clampIndex(Math.round(position.get())));\n      }, 120);\n    };\n    node.addEventListener(\"wheel\", onWheel, { passive: false });\n    return () => {\n      node.removeEventListener(\"wheel\", onWheel);\n      window.clearTimeout(timer);\n    };\n  }, [position]);\n\n  const rows: number[] = [];\n  for (let i = center - WINDOW; i <= center + WINDOW; i++) {\n    if (loop || (i >= 0 && i < count)) {\n      rows.push(i);\n    }\n  }\n\n  return (\n    <div\n      aria-label={column.label}\n      aria-valuemax={count - 1}\n      aria-valuemin={0}\n      aria-valuenow={selectedIndex}\n      aria-valuetext={options[selectedIndex]?.label}\n      className={cn(\n        \"relative cursor-grab touch-none rounded-lg outline-none active:cursor-grabbing\",\n        \"focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-inset\",\n        column.className ?? \"w-16\"\n      )}\n      onKeyDown={handleKeyDown}\n      onLostPointerCapture={handlePointerUp}\n      onPointerCancel={handlePointerUp}\n      onPointerDown={handlePointerDown}\n      onPointerMove={handlePointerMove}\n      onPointerUp={handlePointerUp}\n      ref={columnRef}\n      role=\"spinbutton\"\n      style={{ height: VIEW_HEIGHT }}\n      tabIndex={0}\n    >\n      {/* The fade sits on an inner layer so the focus ring stays crisp. */}\n      <div\n        className=\"absolute inset-0\"\n        style={{\n          maskImage:\n            \"linear-gradient(to bottom, transparent, #000 34%, #000 66%, transparent)\",\n        }}\n      >\n        <Drum\n          drum={drum}\n          optionAt={optionAt}\n          rowClassName=\"text-zinc-400 dark:text-zinc-500\"\n          rows={rows}\n        />\n        <Drum\n          clip\n          drum={drum}\n          optionAt={optionAt}\n          rowClassName=\"font-medium text-zinc-900 dark:text-zinc-50\"\n          rows={rows}\n        />\n      </div>\n    </div>\n  );\n}\n\nfunction Drum({\n  drum,\n  rows,\n  optionAt,\n  rowClassName,\n  clip = false,\n}: {\n  drum: MotionValue<string>;\n  rows: number[];\n  optionAt: (index: number) => WheelOption;\n  rowClassName: string;\n  clip?: boolean;\n}) {\n  return (\n    <div\n      aria-hidden=\"true\"\n      className=\"pointer-events-none absolute inset-0\"\n      style={{\n        perspective: 1000,\n        clipPath: clip\n          ? `inset(calc(50% - ${ITEM_HEIGHT / 2}px) 0 calc(50% - ${ITEM_HEIGHT / 2}px) 0)`\n          : undefined,\n      }}\n    >\n      <motion.div\n        className=\"absolute inset-x-0 top-1/2\"\n        style={{ transform: drum, transformStyle: \"preserve-3d\" }}\n      >\n        {rows.map((index) => (\n          <div\n            className={cn(\n              \"absolute inset-x-0 flex items-center justify-center text-xl tabular-nums [backface-visibility:hidden]\",\n              rowClassName\n            )}\n            key={index}\n            style={{\n              top: -ITEM_HEIGHT / 2,\n              height: ITEM_HEIGHT,\n              transform: `rotateX(${-index * ITEM_ANGLE}deg) translateZ(${RADIUS}px)`,\n            }}\n          >\n            {optionAt(index).label}\n          </div>\n        ))}\n      </motion.div>\n    </div>\n  );\n}\n",
      "path": "/components/kokonutui/wheel-picker.tsx",
      "target": "components/kokonutui/wheel-picker.tsx"
    }
  ]
}