{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "OptionWheel-TS-TW",
	"title": "OptionWheel",
	"description": "Curved option picker that spins via scroll, drag, or arrow keys, fading and tilting items away from the selection.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:component",
			"path": "OptionWheel/OptionWheel.tsx",
			"content": "import { useRef, useState, useCallback, useEffect, type CSSProperties } from 'react';\n\ntype Side = 'left' | 'right';\n\nexport interface OptionWheelProps {\n  items?: string[];\n  defaultSelected?: number;\n  onChange?: (index: number, item: string) => void;\n  textColor?: string;\n  activeColor?: string;\n  side?: Side;\n  fontSize?: number;\n  spacing?: number;\n  curve?: number;\n  tilt?: number;\n  blur?: number;\n  fade?: number;\n  minOpacity?: number;\n  smoothing?: number;\n  inset?: number;\n  loop?: boolean;\n  draggable?: boolean;\n  soundUrl?: string;\n  soundVolume?: number;\n  className?: string;\n}\n\ninterface WheelConfig {\n  count: number;\n  items: string[];\n  rowH: number;\n  curve: number;\n  tilt: number;\n  blur: number;\n  fade: number;\n  minOpacity: number;\n  side: Side;\n  loop: boolean;\n  smoothing: number;\n  draggable: boolean;\n  soundUrl: string;\n  soundVolume: number;\n}\n\nconst DEFAULT_ITEMS = [\n  'Ambient',\n  'House',\n  'Techno',\n  'Jazz',\n  'Lo-Fi',\n  'Synthwave',\n  'Trance',\n  'Funk',\n  'Disco',\n  'Hip-Hop',\n  'Chillwave',\n  'Drum & Bass'\n];\n\nconst OptionWheel = ({\n  items = DEFAULT_ITEMS,\n  defaultSelected = 3,\n  onChange,\n  textColor = '#a6a6a6',\n  activeColor = '#ffffff',\n  side = 'left',\n  fontSize = 3,\n  spacing = 1.4,\n  curve = 1,\n  tilt = 6,\n  blur = 2,\n  fade = 0.25,\n  minOpacity = 0.05,\n  smoothing = 200,\n  inset = 80,\n  loop = false,\n  draggable = true,\n  soundUrl = '',\n  soundVolume = 0.5,\n  className = ''\n}: OptionWheelProps) => {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const itemRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const posRef = useRef(defaultSelected);\n  const targetRef = useRef(defaultSelected);\n  const rafRef = useRef<number | null>(null);\n  const lastRef = useRef(0);\n  const cfgRef = useRef<WheelConfig>({} as WheelConfig);\n  const onChangeRef = useRef(onChange);\n  const selectedRef = useRef(defaultSelected);\n  const wheelTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const dragRef = useRef<{ y: number; start: number; id: number } | null>(null);\n  const dragMovedRef = useRef(false);\n  const audioRef = useRef<HTMLAudioElement | null>(null);\n  const audioUrlRef = useRef('');\n  const lastTickRef = useRef(0);\n  const [selectedIndex, setSelectedIndex] = useState(defaultSelected);\n  const [isDragging, setIsDragging] = useState(false);\n\n  const remPx = typeof window !== 'undefined' ? parseFloat(getComputedStyle(document.documentElement).fontSize) || 16 : 16;\n\n  onChangeRef.current = onChange;\n  cfgRef.current = {\n    count: items.length,\n    items,\n    rowH: Math.max(fontSize * spacing * remPx, 1),\n    curve,\n    tilt,\n    blur,\n    fade,\n    minOpacity,\n    side,\n    loop,\n    smoothing,\n    draggable,\n    soundUrl,\n    soundVolume\n  };\n\n  // Single rAF loop that eases the wheel position toward its target with\n  // frame-rate independent exponential smoothing, then lays every option out\n  // along the curve based on its distance from the current position.\n  const runFrame = useCallback((now: number) => {\n    const dt = Math.min((now - lastRef.current) / 1000, 0.05);\n    lastRef.current = now;\n    const cfg = cfgRef.current;\n    const tau = Math.max(cfg.smoothing, 1) / 1000;\n    const k = 1 - Math.exp(-dt / tau);\n\n    const target = targetRef.current;\n    const cur = posRef.current;\n    let next = cur + (target - cur) * k;\n    const settled = Math.abs(target - next) < 0.001;\n    if (settled) next = target;\n    posRef.current = next;\n\n    const els = itemRefs.current;\n    const n = cfg.count;\n    const mirror = cfg.side === 'right' ? -1 : 1;\n    // Options sit on a circle whose radius keeps the arc length between two\n    // neighbors equal to one row height, so tilt controls how tightly it curls.\n    const tiltRad = (cfg.tilt * Math.PI) / 180;\n    const R = tiltRad > 0.0005 ? cfg.rowH / tiltRad : 0;\n    for (let i = 0; i < n; i++) {\n      const el = els[i];\n      if (!el) continue;\n      let d = i - next;\n      if (cfg.loop && n > 1) {\n        d = ((d % n) + n) % n;\n        if (d > n / 2) d -= n;\n      }\n      const dist = Math.abs(d);\n      let x = 0;\n      let y = d * cfg.rowH;\n      let rot = 0;\n      if (R > 0) {\n        const ang = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, d * tiltRad));\n        y = R * Math.sin(ang);\n        x = -mirror * R * (1 - Math.cos(ang)) * cfg.curve;\n        rot = (mirror * ang * 180) / Math.PI;\n      }\n      el.style.transform = `translate(${x.toFixed(2)}px, calc(${y.toFixed(2)}px - 50%)) rotate(${rot.toFixed(3)}deg)`;\n      el.style.opacity = String(Math.max(cfg.minOpacity, 1 - dist * cfg.fade));\n      el.style.filter = cfg.blur > 0 ? `blur(${(dist * cfg.blur).toFixed(2)}px)` : 'none';\n      el.style.setProperty('--ow-p', Math.max(0, 1 - Math.min(dist, 1)).toFixed(4));\n    }\n\n    rafRef.current = settled ? null : requestAnimationFrame(runFrame);\n  }, []);\n\n  const startLoop = useCallback(() => {\n    if (rafRef.current != null) {\n      cancelAnimationFrame(rafRef.current);\n    }\n    lastRef.current = performance.now();\n    rafRef.current = requestAnimationFrame(runFrame);\n  }, [runFrame]);\n\n  // Optional tick on selection change, throttled so fast scrolling can't spam\n  // it, and with playback failures (e.g. autoplay policies) silently ignored.\n  const playTick = useCallback(() => {\n    const { soundUrl, soundVolume } = cfgRef.current;\n    if (!soundUrl) return;\n    const now = performance.now();\n    if (now - lastTickRef.current < 70) return;\n    lastTickRef.current = now;\n    if (!audioRef.current || audioUrlRef.current !== soundUrl) {\n      audioRef.current = new Audio(soundUrl);\n      audioRef.current.preload = 'auto';\n      audioUrlRef.current = soundUrl;\n    }\n    const audio = audioRef.current;\n    audio.volume = Math.min(Math.max(soundVolume, 0), 1);\n    audio.currentTime = 0;\n    audio.play()?.catch(() => {});\n  }, []);\n\n  const applyTarget = useCallback(\n    (value: number, snap: boolean) => {\n      const cfg = cfgRef.current;\n      let v = value;\n      if (!cfg.loop) v = Math.min(Math.max(v, 0), Math.max(cfg.count - 1, 0));\n      if (snap) v = Math.round(v);\n      targetRef.current = v;\n      const idx = ((Math.round(v) % cfg.count) + cfg.count) % cfg.count;\n      if (idx !== selectedRef.current) {\n        selectedRef.current = idx;\n        setSelectedIndex(idx);\n        onChangeRef.current?.(idx, cfg.items[idx]);\n        playTick();\n      }\n      startLoop();\n    },\n    [startLoop, playTick]\n  );\n\n  // Wheel / touchpad scrolling, registered manually so it can be non-passive.\n  useEffect(() => {\n    const el = rootRef.current;\n    if (!el) return;\n    const onWheel = (e: WheelEvent) => {\n      e.preventDefault();\n      const cfg = cfgRef.current;\n      const delta = e.deltaMode === 1 ? e.deltaY * 24 : e.deltaY;\n      // Cap each event at one step so notchy mouse wheels move exactly one\n      // option per click, while touchpads still scroll continuously.\n      const step = Math.max(-1, Math.min(1, delta / cfg.rowH));\n      applyTarget(targetRef.current + step, false);\n      if (wheelTimerRef.current) clearTimeout(wheelTimerRef.current);\n      wheelTimerRef.current = setTimeout(() => applyTarget(targetRef.current, true), 140);\n    };\n    el.addEventListener('wheel', onWheel, { passive: false });\n    return () => {\n      el.removeEventListener('wheel', onWheel);\n      if (wheelTimerRef.current) clearTimeout(wheelTimerRef.current);\n    };\n  }, [applyTarget]);\n\n  const handlePointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {\n    if (!cfgRef.current.draggable) return;\n    dragRef.current = { y: e.clientY, start: targetRef.current, id: e.pointerId };\n    dragMovedRef.current = false;\n    setIsDragging(true);\n  }, []);\n\n  const handlePointerMove = useCallback(\n    (e: React.PointerEvent<HTMLDivElement>) => {\n      const drag = dragRef.current;\n      if (!drag) return;\n      const dy = e.clientY - drag.y;\n      if (!dragMovedRef.current && Math.abs(dy) > 4) {\n        dragMovedRef.current = true;\n        // Capture only once a real drag starts, so plain clicks still reach\n        // the items and navigate to them.\n        rootRef.current?.setPointerCapture(drag.id);\n      }\n      if (dragMovedRef.current) applyTarget(drag.start - dy / cfgRef.current.rowH, false);\n    },\n    [applyTarget]\n  );\n\n  const handlePointerEnd = useCallback(() => {\n    if (!dragRef.current) return;\n    dragRef.current = null;\n    setIsDragging(false);\n    if (dragMovedRef.current) applyTarget(targetRef.current, true);\n  }, [applyTarget]);\n\n  const handleItemClick = useCallback(\n    (index: number) => {\n      if (dragMovedRef.current) return;\n      const cfg = cfgRef.current;\n      const cur = targetRef.current;\n      let d = index - (((cur % cfg.count) + cfg.count) % cfg.count);\n      if (cfg.loop && cfg.count > 1) {\n        if (d > cfg.count / 2) d -= cfg.count;\n        else if (d < -cfg.count / 2) d += cfg.count;\n      }\n      applyTarget(cur + d, true);\n    },\n    [applyTarget]\n  );\n\n  const handleKeyDown = useCallback(\n    (e: React.KeyboardEvent<HTMLDivElement>) => {\n      let delta: number | null = null;\n      if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') delta = -1;\n      else if (e.key === 'ArrowDown' || e.key === 'ArrowRight') delta = 1;\n      if (delta == null) return;\n      e.preventDefault();\n      applyTarget(Math.round(targetRef.current) + delta, true);\n    },\n    [applyTarget]\n  );\n\n  useEffect(() => {\n    applyTarget(targetRef.current, false);\n  }, [items, fontSize, spacing, curve, tilt, blur, fade, minOpacity, side, loop, smoothing, applyTarget]);\n\n  useEffect(\n    () => () => {\n      if (rafRef.current != null) cancelAnimationFrame(rafRef.current);\n      rafRef.current = null;\n      audioRef.current?.pause();\n    },\n    []\n  );\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"listbox\"\n      tabIndex={0}\n      aria-label=\"Option wheel\"\n      className={`relative h-full w-full select-none overflow-hidden outline-none [touch-action:none] ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}${className ? ` ${className}` : ''}`}\n      style={\n        {\n          '--ow-text-color': textColor,\n          '--ow-active-color': activeColor,\n          '--ow-font-size': `${fontSize}rem`,\n          '--ow-inset': `${inset}px`\n        } as CSSProperties\n      }\n      onPointerDown={handlePointerDown}\n      onPointerMove={handlePointerMove}\n      onPointerUp={handlePointerEnd}\n      onPointerCancel={handlePointerEnd}\n      onKeyDown={handleKeyDown}\n    >\n      {items.map((label, index) => (\n        <div\n          key={`${label}-${index}`}\n          ref={el => {\n            itemRefs.current[index] = el;\n          }}\n          role=\"option\"\n          aria-selected={selectedIndex === index}\n          className={`absolute top-1/2 cursor-pointer whitespace-nowrap leading-none will-change-[transform,opacity,filter] [font-size:var(--ow-font-size)] [color:color-mix(in_srgb,var(--ow-active-color)_calc(var(--ow-p,0)*100%),var(--ow-text-color))] ${\n            side === 'right' ? 'right-[var(--ow-inset)] origin-right' : 'left-[var(--ow-inset)] origin-left'\n          } ${selectedIndex === index ? 'font-medium' : 'font-extralight'}`}\n          onClick={() => handleItemClick(index)}\n        >\n          {label}\n        </div>\n      ))}\n    </div>\n  );\n};\n\nexport default OptionWheel;\n"
		}
	],
	"registryDependencies": [],
	"dependencies": []
}