{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "gooey-pagination", "type": "registry:ui", "title": "Gooey Pagination", "description": "Liquid metaball pagination with SVG gooey filter, sliding active indicator, and spring physics.", "dependencies": [ "motion" ], "registryDependencies": [], "files": [ { "path": "registry/ruixenui/gooey-pagination.tsx", "content": "\"use client\";\n\nimport { useState, useRef, useCallback } from \"react\";\nimport { motion } from \"motion/react\";\n\n/**\n * Gooey Pagination — Rauno Freiberg craft.\n *\n * Liquid dots with SVG gooey filter. Active indicator slides\n * between dots creating a metaball merge/split effect.\n * Glass container. Spring physics. Audio tick on change.\n */\n\n/* ── Audio singleton ── */\n\nlet _a: AudioContext | null = null;\nlet _b: AudioBuffer | null = null;\n\nfunction getCtx(): AudioContext {\n if (!_a)\n _a = new (window.AudioContext ||\n (window as unknown as { webkitAudioContext: typeof AudioContext })\n .webkitAudioContext)();\n if (_a.state === \"suspended\") _a.resume();\n return _a;\n}\n\nfunction getBuf(ac: AudioContext): AudioBuffer {\n if (_b && _b.sampleRate === ac.sampleRate) return _b;\n const len = Math.floor(ac.sampleRate * 0.003);\n const buf = ac.createBuffer(1, len, ac.sampleRate);\n const ch = buf.getChannelData(0);\n for (let i = 0; i < len; i++)\n ch[i] = (Math.random() * 2 - 1) * (1 - i / len) ** 4;\n _b = buf;\n return buf;\n}\n\nfunction tick(ref: React.MutableRefObject) {\n const now = performance.now();\n if (now - ref.current < 25) return;\n ref.current = now;\n try {\n const ac = getCtx();\n const src = ac.createBufferSource();\n const g = ac.createGain();\n src.buffer = getBuf(ac);\n g.gain.value = 0.06;\n src.connect(g).connect(ac.destination);\n src.start();\n } catch {\n /* silent */\n }\n}\n\n/* ── Types ── */\n\ninterface GooeyPaginationProps {\n totalPages?: number;\n onChange?: (page: number) => void;\n sound?: boolean;\n}\n\n/* ── CSS ── */\n\nconst CSS = `.gp{--gp-bg:rgba(255,255,255,.72);--gp-border:rgba(0,0,0,.06);--gp-shadow:0 0 0 .5px rgba(0,0,0,.04),0 2px 4px rgba(0,0,0,.04),0 8px 24px rgba(0,0,0,.06);--gp-ink:0,0,0;--gp-dot:rgba(0,0,0,.12);--gp-active:rgba(0,0,0,.65)}.dark .gp,[data-theme=\"dark\"] .gp{--gp-bg:rgba(30,30,32,.82);--gp-border:rgba(255,255,255,.06);--gp-shadow:0 0 0 .5px rgba(255,255,255,.04),0 2px 4px rgba(0,0,0,.2),0 8px 24px rgba(0,0,0,.3);--gp-ink:255,255,255;--gp-dot:rgba(255,255,255,.15);--gp-active:rgba(255,255,255,.7)}`;\n\n/* ── Constants ── */\n\nconst DOT_SIZE = 10;\nconst DOT_GAP = 16;\nconst SPRING = { type: \"spring\" as const, stiffness: 350, damping: 25 };\n\n/* ── Component ── */\n\nexport function GooeyPagination({\n totalPages = 7,\n onChange,\n sound = true,\n}: GooeyPaginationProps) {\n const [active, setActive] = useState(0);\n const lastSound = useRef(0);\n const filterId = useRef(\n `goo-${Math.random().toString(36).slice(2, 8)}`,\n ).current;\n\n const go = useCallback(\n (next: number) => {\n if (next < 0 || next >= totalPages || next === active) return;\n if (sound) tick(lastSound);\n setActive(next);\n onChange?.(next);\n },\n [active, totalPages, onChange, sound],\n );\n\n const totalW = totalPages * DOT_SIZE + (totalPages - 1) * DOT_GAP;\n\n return (\n \n