{
  "$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<number>) {\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    <div\n      className=\"gp\"\n      style={{\n        display: \"inline-flex\",\n        flexDirection: \"column\",\n        alignItems: \"center\",\n        gap: 12,\n      }}\n    >\n      <style dangerouslySetInnerHTML={{ __html: CSS }} />\n\n      <div\n        style={{\n          display: \"inline-flex\",\n          alignItems: \"center\",\n          gap: 8,\n          padding: \"14px 16px\",\n          background: \"var(--gp-bg)\",\n          border: \"1px solid var(--gp-border)\",\n          boxShadow: \"var(--gp-shadow)\",\n          borderRadius: 28,\n          backdropFilter: \"blur(16px)\",\n          WebkitBackdropFilter: \"blur(16px)\",\n        }}\n      >\n        {/* Prev */}\n        <button\n          onClick={() => go(active - 1)}\n          style={{\n            border: \"none\",\n            background: \"none\",\n            padding: 4,\n            cursor: active === 0 ? \"default\" : \"pointer\",\n            opacity: active === 0 ? 0.15 : 0.4,\n            display: \"flex\",\n            transition: \"opacity .12s\",\n          }}\n        >\n          <svg width=\"12\" height=\"12\" viewBox=\"0 0 12 12\" fill=\"none\">\n            <path\n              d=\"M7.5 2.5L4.5 6L7.5 9.5\"\n              stroke={`rgba(var(--gp-ink),.6)`}\n              strokeWidth=\"1.3\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            />\n          </svg>\n        </button>\n\n        {/* Gooey dots */}\n        <div\n          style={{ position: \"relative\", width: totalW, height: DOT_SIZE + 12 }}\n        >\n          <svg style={{ position: \"absolute\", width: 0, height: 0 }}>\n            <defs>\n              <filter id={filterId}>\n                <feGaussianBlur\n                  in=\"SourceGraphic\"\n                  stdDeviation=\"4\"\n                  result=\"blur\"\n                />\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 18 -7\"\n                  result=\"goo\"\n                />\n                <feComposite in=\"SourceGraphic\" in2=\"goo\" operator=\"atop\" />\n              </filter>\n            </defs>\n          </svg>\n\n          <div\n            style={{\n              display: \"flex\",\n              alignItems: \"center\",\n              gap: DOT_GAP,\n              position: \"relative\",\n              height: \"100%\",\n              filter: `url(#${filterId})`,\n              paddingTop: 6,\n            }}\n          >\n            {Array.from({ length: totalPages }).map((_, i) => (\n              <div\n                key={i}\n                onClick={() => go(i)}\n                style={{\n                  width: DOT_SIZE,\n                  height: DOT_SIZE,\n                  borderRadius: \"50%\",\n                  background: \"var(--gp-dot)\",\n                  cursor: \"pointer\",\n                  flexShrink: 0,\n                }}\n              />\n            ))}\n\n            <motion.div\n              animate={{ x: active * (DOT_SIZE + DOT_GAP) }}\n              transition={SPRING}\n              style={{\n                position: \"absolute\",\n                top: 6,\n                left: 0,\n                width: DOT_SIZE + 4,\n                height: DOT_SIZE + 4,\n                marginTop: -2,\n                marginLeft: -2,\n                borderRadius: \"50%\",\n                background: \"var(--gp-active)\",\n              }}\n            />\n          </div>\n        </div>\n\n        {/* Next */}\n        <button\n          onClick={() => go(active + 1)}\n          style={{\n            border: \"none\",\n            background: \"none\",\n            padding: 4,\n            cursor: active === totalPages - 1 ? \"default\" : \"pointer\",\n            opacity: active === totalPages - 1 ? 0.15 : 0.4,\n            display: \"flex\",\n            transition: \"opacity .12s\",\n          }}\n        >\n          <svg width=\"12\" height=\"12\" viewBox=\"0 0 12 12\" fill=\"none\">\n            <path\n              d=\"M4.5 2.5L7.5 6L4.5 9.5\"\n              stroke={`rgba(var(--gp-ink),.6)`}\n              strokeWidth=\"1.3\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            />\n          </svg>\n        </button>\n      </div>\n\n      <span\n        style={{\n          fontSize: 11,\n          fontWeight: 450,\n          color: `rgba(var(--gp-ink),.3)`,\n          fontVariantNumeric: \"tabular-nums\",\n        }}\n      >\n        {active + 1} / {totalPages}\n      </span>\n    </div>\n  );\n}\n\nexport default GooeyPagination;\n",
      "type": "registry:ui",
      "target": "components/ruixen/gooey-pagination.tsx"
    }
  ]
}