{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "bounce-sidebar",
  "title": "Bounce Sidebar",
  "description": "A sidebar navigation with a bouncing dot indicator that smoothly animates between items using spring physics.",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "components/ui/bounce-sidebar.tsx",
      "type": "registry:ui",
      "content": "'use client'\n\nimport { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react'\nimport {\n  motion,\n  stagger,\n  useAnimate,\n  useMotionValue,\n  useReducedMotion,\n  type Transition,\n  type Variants,\n} from 'motion/react'\nimport { cn } from '@/lib/utils'\nimport { playHoverSound, playClickSound, playBounceSound } from '@/lib/sound'\n\nconst EASE_OUT = [0.22, 1, 0.36, 1] as const\n\nexport type BounceSidebarProps = {\n  items: string[]\n  value?: number\n  defaultValue?: number\n  onChange?: (index: number) => void\n  dotColor?: string\n  className?: string\n}\n\nconst itemClass = (active: boolean) =>\n  cn(\n    'flex w-full cursor-pointer items-center rounded-lg p-1 text-left text-base',\n    'transition-colors duration-150',\n    'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 focus-visible:ring-offset-(--color-bg)',\n    active ? 'text-foreground' : 'text-foreground/55',\n  )\n\nexport function BounceSidebar({\n  items,\n  value,\n  defaultValue = 0,\n  onChange,\n  dotColor = 'var(--color-accent)',\n  className,\n}: BounceSidebarProps) {\n  const [internalValue, setInternalValue] = useState(defaultValue)\n  const activeIndex = value ?? internalValue\n\n  const [scope, animate] = useAnimate()\n  const itemRefs = useRef<(HTMLLIElement | null)[]>([])\n  const prevY = useRef<number | null>(null)\n\n  const reduceMotion = useReducedMotion()\n\n  const dotX = useMotionValue(0)\n  const dotY = useMotionValue(0)\n\n  const [dotSize, setDotSize] = useState(6)\n\n  const getTargetY = (index: number, size: number): number | null => {\n    const el = itemRefs.current[index]\n    if (!el) return null\n    const dpr = window.devicePixelRatio || 1\n    return Math.round((el.offsetTop + el.offsetHeight / 2 - size / 2) * dpr) / dpr\n  }\n\n  useEffect(() => {\n    let cancelled = false\n    const dpr = window.devicePixelRatio || 1\n    const computedSize = Math.round(6 * dpr) / dpr\n\n    const initialIndex = activeIndex\n    const snap = () => {\n      if (cancelled) return\n      const toY = getTargetY(initialIndex, computedSize)\n      if (toY === null) return\n      dotX.set(0)\n      dotY.set(toY)\n      prevY.current = toY\n    }\n\n    const ro = new ResizeObserver(snap)\n    itemRefs.current.forEach((el) => el && ro.observe(el))\n    setDotSize(computedSize)\n\n    const raf = requestAnimationFrame(snap)\n    document.fonts?.ready.then(snap)\n    return () => {\n      cancelled = true\n      cancelAnimationFrame(raf)\n      ro.disconnect()\n    }\n  }, [])\n\n  useEffect(() => {\n    const toY = getTargetY(activeIndex, dotSize)\n    if (toY === null) return\n\n    if (prevY.current === null) {\n      dotX.set(0)\n      dotY.set(toY)\n      prevY.current = toY\n      return\n    }\n\n    const fromY = prevY.current\n    const delta = toY - fromY\n    prevY.current = toY\n    if (delta === 0) return\n    const distance = Math.abs(delta)\n    playBounceSound()\n\n    const yDuration = 0.45\n    const yTransition: Transition = { duration: yDuration, ease: EASE_OUT }\n\n    const strength = Math.min(0.6, 20 / distance)\n    const peakX = -strength * distance\n\n    animate(dotY, toY, yTransition)\n    animate(dotX, [0, peakX, 0], {\n      duration: yDuration,\n      ease: EASE_OUT,\n      times: [0, 0.4, 1],\n    })\n  }, [activeIndex, animate, dotX, dotY, dotSize, reduceMotion])\n\n  const select = useCallback(\n    (index: number) => {\n      playBounceSound()\n      if (value === undefined) setInternalValue(index)\n      onChange?.(index)\n    },\n    [value, onChange],\n  )\n\n  const handleKeyDown = useCallback(\n    (e: KeyboardEvent<HTMLUListElement>) => {\n      const count = items.length\n      if (count === 0) return\n\n      let next = activeIndex\n\n      if (e.key === 'ArrowDown') {\n        e.preventDefault()\n        next = (activeIndex + 1) % count\n      } else if (e.key === 'ArrowUp') {\n        e.preventDefault()\n        next = (activeIndex - 1 + count) % count\n      } else if (e.key === 'Home') {\n        e.preventDefault()\n        next = 0\n      } else if (e.key === 'End') {\n        e.preventDefault()\n        next = count - 1\n      }\n\n      if (next !== activeIndex) {\n        select(next)\n        const buttons = itemRefs.current.map((el) => el?.querySelector('button'))\n        buttons[next]?.focus()\n      }\n    },\n    [activeIndex, items.length, select],\n  )\n\n  return (\n    <ul\n      role=\"listbox\"\n      aria-label=\"Navigation\"\n      onKeyDown={handleKeyDown}\n      className={cn('relative flex flex-col gap-1 ps-6', className)}\n    >\n      <motion.span\n        ref={scope}\n        aria-hidden\n        className=\"absolute inset-s-2 top-0 rounded-full\"\n        style={{\n          x: dotX,\n          y: dotY,\n          width: dotSize,\n          height: dotSize,\n          backgroundColor: dotColor,\n        }}\n      />\n\n      {items.map((item, index) => {\n        const isActive = index === activeIndex\n        return (\n          <li\n            key={item}\n            ref={(el) => {\n              itemRefs.current[index] = el\n            }}\n            role=\"option\"\n            aria-selected={isActive}\n          >\n            <motion.button\n              type=\"button\"\n              tabIndex={isActive ? 0 : -1}\n              onMouseEnter={() => playHoverSound()}\n              onPointerDown={() => select(index)}\n              onClick={() => select(index)}\n              aria-current={isActive ? 'true' : undefined}\n              whileTap={reduceMotion ? undefined : { scale: 0.97 }}\n              className={itemClass(isActive)}\n            >\n              {item}\n            </motion.button>\n          </li>\n        )\n      })}\n    </ul>\n  )\n}\n"
    },
    {
      "path": "lib/sound.ts",
      "type": "registry:lib",
      "content": "'use client'\n\nconst MUTE_KEY = 'sound-muted'\nconst MUTE_EVENT = 'sound-mute-change'\n\nlet muted = false\nif (typeof window !== 'undefined') {\n  try {\n    muted = localStorage.getItem(MUTE_KEY) === '1'\n  } catch {}\n}\n\nexport function isSoundMuted(): boolean {\n  return muted\n}\n\nexport function setSoundMuted(value: boolean) {\n  muted = value\n  if (typeof window !== 'undefined') {\n    try {\n      localStorage.setItem(MUTE_KEY, value ? '1' : '0')\n    } catch {}\n    window.dispatchEvent(new CustomEvent(MUTE_EVENT))\n  }\n}\n\nexport function toggleSoundMuted(): boolean {\n  setSoundMuted(!muted)\n  return muted\n}\n\nexport function subscribeSoundMuted(callback: () => void) {\n  if (typeof window === 'undefined') return () => {}\n  window.addEventListener(MUTE_EVENT, callback)\n  const onStorage = (e: StorageEvent) => {\n    if (e.key === MUTE_KEY) callback()\n  }\n  window.addEventListener('storage', onStorage)\n  return () => {\n    window.removeEventListener(MUTE_EVENT, callback)\n    window.removeEventListener('storage', onStorage)\n  }\n}\n\nlet audioCtx: AudioContext | null = null\n\nfunction initAudio(): AudioContext | null {\n  if (typeof window === 'undefined') return null\n  if (!audioCtx) {\n    const AudioContextClass =\n      window.AudioContext ||\n      (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext\n    if (AudioContextClass) {\n      audioCtx = new AudioContextClass()\n    }\n  }\n  if (audioCtx && audioCtx.state === 'suspended') {\n    audioCtx.resume().catch(() => {})\n  }\n  return audioCtx\n}\n\nif (typeof window !== 'undefined') {\n  const unlock = () => {\n    initAudio()\n    window.removeEventListener('pointerdown', unlock)\n    window.removeEventListener('keydown', unlock)\n    window.removeEventListener('touchstart', unlock)\n  }\n  window.addEventListener('pointerdown', unlock, { passive: true })\n  window.addEventListener('keydown', unlock, { passive: true })\n  window.addEventListener('touchstart', unlock, { passive: true })\n}\n\nexport function getAudioContext(): AudioContext | null {\n  return initAudio()\n}\n\nexport function playHoverSound(volume = 0.12, pitch = 1.2) {\n  if (muted) return\n  try {\n    const ctx = getAudioContext()\n    if (!ctx) return\n    const now = ctx.currentTime\n\n    const osc = ctx.createOscillator()\n    const gain = ctx.createGain()\n\n    osc.type = 'sine'\n    osc.frequency.setValueAtTime(1400 * pitch, now)\n    osc.frequency.exponentialRampToValueAtTime(350 * pitch, now + 0.012)\n\n    gain.gain.setValueAtTime(volume, now)\n    gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.012)\n\n    osc.connect(gain)\n    gain.connect(ctx.destination)\n\n    osc.start(now)\n    osc.stop(now + 0.012)\n  } catch {}\n}\n\nexport function playClickSound(volume = 0.2, pitch = 1.0) {\n  if (muted) return\n  try {\n    const ctx = getAudioContext()\n    if (!ctx) return\n    const now = ctx.currentTime\n\n    const osc = ctx.createOscillator()\n    const gain = ctx.createGain()\n\n    osc.type = 'triangle'\n    osc.frequency.setValueAtTime(850 * pitch, now)\n    osc.frequency.exponentialRampToValueAtTime(160 * pitch, now + 0.02)\n\n    gain.gain.setValueAtTime(volume, now)\n    gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.02)\n\n    osc.connect(gain)\n    gain.connect(ctx.destination)\n\n    osc.start(now)\n    osc.stop(now + 0.02)\n  } catch {}\n}\n\nexport function playTickSound(volume = 0.1, pitch = 1.5) {\n  if (muted) return\n  try {\n    const ctx = getAudioContext()\n    if (!ctx) return\n    const now = ctx.currentTime\n\n    const osc = ctx.createOscillator()\n    const gain = ctx.createGain()\n\n    osc.type = 'sine'\n    osc.frequency.setValueAtTime(1800 * pitch, now)\n    osc.frequency.exponentialRampToValueAtTime(450 * pitch, now + 0.01)\n\n    gain.gain.setValueAtTime(volume, now)\n    gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.01)\n\n    osc.connect(gain)\n    gain.connect(ctx.destination)\n\n    osc.start(now)\n    osc.stop(now + 0.01)\n  } catch {}\n}\n\nexport function playBounceSound(volume = 0.3, pitch = 1.0) {\n  if (muted) return\n  try {\n    const ctx = getAudioContext()\n    if (!ctx) return\n    const now = ctx.currentTime\n\n    const osc = ctx.createOscillator()\n    const gain = ctx.createGain()\n\n    osc.type = 'sine'\n    osc.frequency.setValueAtTime(260 * pitch, now)\n    osc.frequency.exponentialRampToValueAtTime(650 * pitch, now + 0.04)\n    osc.frequency.exponentialRampToValueAtTime(190 * pitch, now + 0.14)\n\n    gain.gain.setValueAtTime(volume, now)\n    gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.14)\n\n    osc.connect(gain)\n    gain.connect(ctx.destination)\n\n    osc.start(now)\n    osc.stop(now + 0.14)\n\n    const subOsc = ctx.createOscillator()\n    const subGain = ctx.createGain()\n\n    subOsc.type = 'triangle'\n    subOsc.frequency.setValueAtTime(420 * pitch, now)\n    subOsc.frequency.exponentialRampToValueAtTime(120 * pitch, now + 0.05)\n\n    subGain.gain.setValueAtTime(volume * 0.7, now)\n    subGain.gain.exponentialRampToValueAtTime(0.0001, now + 0.05)\n\n    subOsc.connect(subGain)\n    subGain.connect(ctx.destination)\n\n    subOsc.start(now)\n    subOsc.stop(now + 0.05)\n  } catch {}\n}\n"
    }
  ],
  "type": "registry:ui"
}
