{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "goo-dropdown",
  "title": "Gooey Dropdown",
  "description": "A dropdown menu with a gooey SVG filter effect that morphs a trigger pill into the panel using spring physics and CSS shape interpolation.",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "components/ui/goo-dropdown.tsx",
      "type": "registry:ui",
      "content": "'use client'\n\nimport React, { useEffect, useId, useMemo, useRef, useState } from 'react'\nimport { animate, useMotionValue, useMotionValueEvent, useReducedMotion } from 'motion/react'\nimport { cn } from '@/lib/utils'\nimport { playHoverSound, playClickSound } from '@/lib/sound'\n\nexport type DropdownItem = {\n  label: string\n  onClick?: () => void\n}\n\ntype SpringConfig = {\n  type: 'spring'\n  stiffness?: number\n  damping?: number\n  mass?: number\n  bounce?: number\n  visualDuration?: number\n}\n\nexport type GooDropdownProps = {\n  trigger?: string\n  items?: DropdownItem[]\n  width?: number\n  align?: 'start' | 'end'\n  gap?: number\n  itemHeight?: number\n  buttonRadius?: number\n  panelRadius?: number\n  fill?: string\n  gooStrength?: number\n  spring?: SpringConfig\n  className?: string\n}\n\nconst BTN_W = 78\nconst BTN_H = 34\nconst PANEL_PAD = 6\nconst FILL = 'var(--color-card)'\n\nconst DEFAULT_ITEMS: DropdownItem[] = [\n  { label: 'Copy link', onClick: () => {} },\n  { label: 'Share on X', onClick: () => {} },\n  { label: 'Embed', onClick: () => {} },\n]\n\nconst DEFAULT_SPRING: SpringConfig = {\n  type: 'spring',\n  visualDuration: 0.3,\n  bounce: 0.15,\n}\n\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t\n\nfunction roundedRectShape(x: number, y: number, w: number, h: number, radius: number) {\n  const r = Math.max(0, Math.min(radius, w / 2, h / 2))\n  const k = r * 0.5523\n  const x1 = x\n  const y1 = y\n  const x2 = x + w\n  const y2 = y + h\n  const p = (n: number) => `${n.toFixed(3)}px`\n\n  return (\n    `shape(from ${p(x1 + r)} ${p(y1)}, ` +\n    `line to ${p(x2 - r)} ${p(y1)}, ` +\n    `curve to ${p(x2)} ${p(y1 + r)} with ${p(x2 - r + k)} ${p(y1)} / ${p(x2)} ${p(y1 + r - k)}, ` +\n    `line to ${p(x2)} ${p(y2 - r)}, ` +\n    `curve to ${p(x2 - r)} ${p(y2)} with ${p(x2)} ${p(y2 - r + k)} / ${p(x2 - r + k)} ${p(y2)}, ` +\n    `line to ${p(x1 + r)} ${p(y2)}, ` +\n    `curve to ${p(x1)} ${p(y2 - r)} with ${p(x1 + r - k)} ${p(y2)} / ${p(x1)} ${p(y2 - r + k)}, ` +\n    `line to ${p(x1)} ${p(y1 + r)}, ` +\n    `curve to ${p(x1 + r)} ${p(y1)} with ${p(x1)} ${p(y1 + r - k)} / ${p(x1 + r - k)} ${p(y1)}, ` +\n    `close)`\n  )\n}\n\nexport function GooDropdown({\n  trigger = 'Share',\n  items = DEFAULT_ITEMS,\n  width = 240,\n  align = 'end',\n  gap = 18,\n  itemHeight = 40,\n  buttonRadius = 12,\n  panelRadius = 20,\n  fill = FILL,\n  gooStrength = 8,\n  spring = DEFAULT_SPRING,\n  className,\n}: GooDropdownProps) {\n  const [open, setOpen] = useState(false)\n  const [activeIndex, setActiveIndex] = useState(0)\n  const shouldReduceMotion = useReducedMotion()\n  const filterId = useId().replace(/[:]/g, '')\n\n  const rootRef = useRef<HTMLDivElement>(null)\n  const panelRef = useRef<HTMLDivElement>(null)\n  const contentRef = useRef<HTMLDivElement>(null)\n  const triggerRef = useRef<HTMLButtonElement>(null)\n  const itemRefs = useRef<(HTMLButtonElement | null)[]>([])\n\n  const geo = useMemo(() => {\n    const panelTop = BTN_H + gap\n    const panelH = items.length * itemHeight + PANEL_PAD * 2\n    const btnX = align === 'end' ? width - BTN_W : 0\n    const closed = { x: btnX, y: 0, w: BTN_W, h: BTN_H, r: buttonRadius }\n    const open = { x: 0, y: panelTop, w: width, h: panelH, r: panelRadius }\n    return { panelTop, panelH, btnX, closed, open, layerH: panelTop + panelH }\n  }, [items.length, width, align, gap, itemHeight, buttonRadius, panelRadius])\n\n  const shapeAt = useMemo(() => {\n    const { closed, open } = geo\n    return (t: number) =>\n      roundedRectShape(\n        lerp(closed.x, open.x, t),\n        lerp(closed.y, open.y, t),\n        lerp(closed.w, open.w, t),\n        lerp(closed.h, open.h, t),\n        lerp(closed.r, open.r, t),\n      )\n  }, [geo])\n\n  const closedShape = shapeAt(0)\n\n  const progress = useMotionValue(0)\n\n  useMotionValueEvent(progress, 'change', (v) => {\n    const shape = shapeAt(v)\n    if (panelRef.current) panelRef.current.style.clipPath = shape\n    if (contentRef.current) contentRef.current.style.clipPath = shape\n  })\n\n  useEffect(() => {\n    if (shouldReduceMotion) {\n      progress.set(open ? 1 : 0)\n      return\n    }\n    const config = {\n      ...spring,\n      visualDuration: open\n        ? (spring.visualDuration ?? 0.3)\n        : spring.visualDuration\n          ? spring.visualDuration * 0.7\n          : 0.2,\n    }\n    const animation = animate(progress, open ? 1 : 0, config)\n    return () => animation.stop()\n  }, [open, progress, spring, shouldReduceMotion])\n\n  const openMenu = () => {\n    setOpen(true)\n    setActiveIndex(0)\n    itemRefs.current[0]?.focus()\n  }\n\n  const closeMenu = () => {\n    setOpen(false)\n    triggerRef.current?.focus()\n  }\n\n  useEffect(() => {\n    if (!open) return\n    const onPointerDown = (e: PointerEvent) => {\n      if (rootRef.current && !rootRef.current.contains(e.target as Node)) {\n        closeMenu()\n      }\n    }\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') closeMenu()\n    }\n    window.addEventListener('pointerdown', onPointerDown)\n    window.addEventListener('keydown', onKey)\n    return () => {\n      window.removeEventListener('pointerdown', onPointerDown)\n      window.removeEventListener('keydown', onKey)\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [open])\n\n  const select = (item: DropdownItem) => {\n    playClickSound()\n    item.onClick?.()\n    closeMenu()\n  }\n\n  const onItemKeyDown = (e: React.KeyboardEvent, index: number) => {\n    if (e.key === 'ArrowDown') {\n      e.preventDefault()\n      const next = (index + 1) % items.length\n      setActiveIndex(next)\n      itemRefs.current[next]?.focus()\n    } else if (e.key === 'ArrowUp') {\n      e.preventDefault()\n      const prev = (index - 1 + items.length) % items.length\n      setActiveIndex(prev)\n      itemRefs.current[prev]?.focus()\n    } else if (e.key === 'Home') {\n      e.preventDefault()\n      setActiveIndex(0)\n      itemRefs.current[0]?.focus()\n    } else if (e.key === 'End') {\n      e.preventDefault()\n      const last = items.length - 1\n      setActiveIndex(last)\n      itemRefs.current[last]?.focus()\n    }\n  }\n\n  return (\n    <div\n      ref={rootRef}\n      className={cn('relative select-none', className)}\n      style={{ width, height: geo.layerH }}\n    >\n      <svg className=\"absolute h-0 w-0\" aria-hidden>\n        <defs>\n          <filter id={filterId}>\n            <feGaussianBlur in=\"SourceGraphic\" stdDeviation={gooStrength} result=\"blur\" />\n            <feColorMatrix\n              in=\"blur\"\n              mode=\"matrix\"\n              values=\"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 22 -10\"\n              result=\"goo\"\n            />\n            <feComposite in=\"SourceGraphic\" in2=\"goo\" operator=\"atop\" />\n          </filter>\n        </defs>\n      </svg>\n\n      <div\n        className=\"pointer-events-none absolute inset-0\"\n        style={{ filter: shouldReduceMotion ? 'none' : `url(#${filterId})` }}\n      >\n        <div\n          className=\"absolute top-0\"\n          style={{\n            left: geo.btnX,\n            width: BTN_W,\n            height: BTN_H,\n            borderRadius: buttonRadius,\n            background: fill,\n          }}\n        />\n        <div\n          ref={panelRef}\n          className=\"absolute inset-0 will-change-[clip-path]\"\n          style={{ background: fill, clipPath: closedShape }}\n        />\n      </div>\n\n      <div className=\"absolute inset-0\">\n        <button\n          ref={triggerRef}\n          type=\"button\"\n          onMouseEnter={() => playHoverSound()}\n          onClick={() => {\n            playClickSound()\n            open ? closeMenu() : openMenu()\n          }}\n          aria-expanded={open}\n          aria-haspopup=\"menu\"\n          className=\"absolute top-0 flex items-center justify-center text-[15px] text-(--color-card-foreground) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-bg) rounded-[12px]\"\n          style={{\n            left: geo.btnX,\n            width: BTN_W,\n            height: BTN_H,\n            borderRadius: buttonRadius,\n          }}\n        >\n          {trigger}\n        </button>\n\n        <div\n          ref={contentRef}\n          role=\"menu\"\n          aria-label=\"Actions\"\n          className=\"absolute inset-0 will-change-[clip-path]\"\n          style={{\n            clipPath: closedShape,\n            pointerEvents: open ? 'auto' : 'none',\n          }}\n        >\n          <div\n            className=\"absolute inset-x-0\"\n            style={{\n              top: geo.panelTop,\n              height: geo.panelH,\n              padding: PANEL_PAD,\n            }}\n          >\n            {items.map((item, index) => (\n              <button\n                key={item.label}\n                ref={(el) => {\n                  itemRefs.current[index] = el\n                }}\n                role=\"menuitem\"\n                type=\"button\"\n                tabIndex={open && activeIndex === index ? 0 : -1}\n                onClick={() => select(item)}\n                onKeyDown={(e) => onItemKeyDown(e, index)}\n                style={{ height: itemHeight }}\n                className=\"flex w-full items-center rounded-[14px] px-3 text-left text-[15px] text-(--color-muted) transition-colors duration-150 hover:bg-(--color-surface) hover:text-(--color-card-foreground) focus-visible:outline-none focus-visible:bg-(--color-surface) focus-visible:text-(--color-card-foreground)\"\n              >\n                {item.label}\n              </button>\n            ))}\n          </div>\n        </div>\n      </div>\n    </div>\n  )\n}\n\nexport default GooDropdown\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"
}
