{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "gooey-dock", "type": "registry:ui", "title": "Gooey Dock", "description": "Horizontal dock with proximity-based magnification — items grow as the cursor approaches, spring physics.", "dependencies": [ "motion" ], "files": [ { "path": "registry/ruixenui/gooey-dock.tsx", "content": "\"use client\";\n\nimport { useRef, useState, type ReactNode } from \"react\";\nimport {\n motion,\n useMotionValue,\n useSpring,\n useTransform,\n AnimatePresence,\n type MotionValue,\n} from \"motion/react\";\n\n/**\n * Gooey Dock — proximity magnification dock.\n *\n * Cosine-based scaling curve. Items lift on the Y-axis\n * as they grow — forming a subtle arch. Background\n * brightens on proximity. Icons scale with their cell.\n * Labels spring in / out with AnimatePresence.\n *\n * The interaction IS the design.\n */\n\n/* ── Types ── */\n\nexport interface DockItem {\n icon: ReactNode;\n label: string;\n onClick?: () => void;\n active?: boolean;\n}\n\nexport interface GooeyDockProps {\n items: DockItem[];\n sound?: boolean;\n}\n\n/* ── Constants ── */\n\nconst BASE = 40;\nconst PEAK = 58;\nconst LIFT = 14;\nconst RADIUS = 150;\n\nconst SPRING = { mass: 0.1, stiffness: 200, damping: 14 };\n\n/* ── Audio ── */\n\nlet _ctx: AudioContext | null = null;\nlet _buf: AudioBuffer | null = null;\n\nfunction audioCtx() {\n if (!_ctx) {\n _ctx = new (window.AudioContext ||\n (window as unknown as { webkitAudioContext: typeof AudioContext })\n .webkitAudioContext)();\n }\n if (_ctx.state === \"suspended\") _ctx.resume();\n return _ctx;\n}\n\nfunction ensureBuf(ac: AudioContext): AudioBuffer {\n if (_buf && _buf.sampleRate === ac.sampleRate) return _buf;\n const rate = ac.sampleRate;\n const len = Math.floor(rate * 0.003);\n const buf = ac.createBuffer(1, len, rate);\n const ch = buf.getChannelData(0);\n for (let i = 0; i < len; i++) {\n const t = i / len;\n ch[i] = (Math.random() * 2 - 1) * (1 - t) ** 4;\n }\n _buf = buf;\n return buf;\n}\n\nfunction playTick(last: React.MutableRefObject) {\n const now = performance.now();\n if (now - last.current < 80) return;\n last.current = now;\n try {\n const ac = audioCtx();\n const buf = ensureBuf(ac);\n const src = ac.createBufferSource();\n const gain = ac.createGain();\n src.buffer = buf;\n src.playbackRate.value = 1.2;\n gain.gain.value = 0.035;\n src.connect(gain);\n gain.connect(ac.destination);\n src.start();\n } catch {\n /* silent */\n }\n}\n\n/* ── Cosine falloff ── */\n\nfunction cosineScale(d: number): number {\n const abs = Math.abs(d);\n if (abs > RADIUS) return 0;\n return (1 + Math.cos((abs / RADIUS) * Math.PI)) / 2;\n}\n\n/* ── Theme ── */\n\nconst GD_CSS = `.gd{--gd-ink:0,0,0}.dark .gd,[data-theme=\"dark\"] .gd{--gd-ink:255,255,255}`;\n\n/* ── Dock Icon ── */\n\nfunction DockIcon({\n icon,\n label,\n active,\n onClick,\n mouseX,\n sound,\n lastSound,\n}: DockItem & {\n mouseX: MotionValue;\n sound: boolean;\n lastSound: React.MutableRefObject;\n}) {\n const ref = useRef(null);\n const [hovered, setHovered] = useState(false);\n\n const distance = useTransform(mouseX, (val) => {\n const el = ref.current;\n if (!el) return Infinity;\n const rect = el.getBoundingClientRect();\n return val - rect.left - rect.width / 2;\n });\n\n /* Size — cosine curve */\n const sizeRaw = useTransform(distance, (d) => {\n const t = cosineScale(d);\n return BASE + (PEAK - BASE) * t;\n });\n const size = useSpring(sizeRaw, SPRING);\n\n /* Y lift — items arch upward */\n const liftRaw = useTransform(distance, (d) => {\n const t = cosineScale(d);\n return -LIFT * t;\n });\n const y = useSpring(liftRaw, SPRING);\n\n /* Background opacity — subtle fill on proximity */\n const bgRaw = useTransform(distance, (d) => {\n const t = cosineScale(d);\n return t * 0.08;\n });\n const bgOpacity = useSpring(bgRaw, SPRING);\n\n /* Icon scale — grows with cell */\n const iconScaleRaw = useTransform(size, [BASE, PEAK], [1, 1.2]);\n const iconScale = useSpring(iconScaleRaw, SPRING);\n\n return (\n `rgba(var(--gd-ink),${v})`,\n ),\n }}\n onClick={onClick}\n onMouseEnter={() => {\n setHovered(true);\n if (sound) playTick(lastSound);\n }}\n onMouseLeave={() => setHovered(false)}\n >\n {/* Label — springs in */}\n \n {hovered && (\n \n {label}\n \n )}\n \n\n {/* Icon — scales with cell */}\n \n {icon}\n \n\n {/* Active dot */}\n {active && (\n \n )}\n \n );\n}\n\n/* ── Dock ── */\n\nexport function GooeyDock({ items, sound = true }: GooeyDockProps) {\n const mouseX = useMotionValue(Infinity);\n const lastSound = useRef(0);\n\n return (\n mouseX.set(e.clientX)}\n onMouseLeave={() => mouseX.set(Infinity)}\n style={{\n display: \"flex\",\n alignItems: \"flex-end\",\n gap: 2,\n padding: \"8px 12px 10px\",\n borderRadius: 18,\n border: \"1px solid rgba(var(--gd-ink),0.06)\",\n background: \"rgba(var(--gd-ink),0.02)\",\n }}\n >\n