{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "OptionWheel-JS-CSS",
	"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:file",
			"path": "OptionWheel.css",
			"target": "@components/OptionWheel.css",
			"content": ".option-wheel {\n  --ow-text-color: #a6a6a6;\n  --ow-active-color: #ffffff;\n  --ow-font-size: 3rem;\n  --ow-inset: 80px;\n\n  position: relative;\n  width: 100%;\n  height: 100%;\n  overflow: hidden;\n  cursor: grab;\n  user-select: none;\n  touch-action: none;\n  outline: none;\n}\n\n.option-wheel--dragging {\n  cursor: grabbing;\n}\n\n/* Each option is absolutely centered, then offset along the curve by the\n   rAF loop through transform/opacity/filter, so everything stays in step. */\n.option-wheel__item {\n  position: absolute;\n  top: 50%;\n  left: var(--ow-inset);\n  white-space: nowrap;\n  font-size: var(--ow-font-size);\n  line-height: 1;\n  font-weight: 200;\n  transform-origin: left center;\n  cursor: pointer;\n  will-change: transform, opacity, filter;\n  /* --ow-p goes 0 -> 1 as an option approaches the middle of the wheel */\n  color: color-mix(in srgb, var(--ow-active-color) calc(var(--ow-p, 0) * 100%), var(--ow-text-color));\n}\n\n.option-wheel--right .option-wheel__item {\n  left: auto;\n  right: var(--ow-inset);\n  transform-origin: right center;\n}\n\n.option-wheel__item--selected {\n  font-weight: 500;\n}\n"
		},
		{
			"type": "registry:component",
			"path": "OptionWheel.jsx",
			"content": "import { useRef, useState, useCallback, useEffect } from 'react';\nimport './OptionWheel.css';\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}) => {\n  const rootRef = useRef(null);\n  const itemRefs = useRef([]);\n  const posRef = useRef(defaultSelected);\n  const targetRef = useRef(defaultSelected);\n  const rafRef = useRef(null);\n  const lastRef = useRef(0);\n  const cfgRef = useRef({});\n  const onChangeRef = useRef(onChange);\n  const selectedRef = useRef(defaultSelected);\n  const wheelTimerRef = useRef(null);\n  const dragRef = useRef(null);\n  const dragMovedRef = useRef(false);\n  const audioRef = useRef(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 => {\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, snap) => {\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 => {\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 => {\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 => {\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 => {\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 => {\n      let delta = 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={`option-wheel${side === 'right' ? ' option-wheel--right' : ''}${isDragging ? ' option-wheel--dragging' : ''}${className ? ` ${className}` : ''}`}\n      style={{\n        '--ow-text-color': textColor,\n        '--ow-active-color': activeColor,\n        '--ow-font-size': `${fontSize}rem`,\n        '--ow-inset': `${inset}px`\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={`option-wheel__item${selectedIndex === index ? ' option-wheel__item--selected' : ''}`}\n          onClick={() => handleItemClick(index)}\n        >\n          {label}\n        </div>\n      ))}\n    </div>\n  );\n};\n\nexport default OptionWheel;\n"
		}
	],
	"registryDependencies": [],
	"dependencies": []
}