{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "icon-pagination",
  "type": "registry:ui",
  "title": "Icon Pagination",
  "description": "Colorful dot pagination with mouse proximity wave effect, cosine falloff lift, and spring physics.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/ruixenui/icon-pagination.tsx",
      "content": "\"use client\";\n\nimport { useState, useRef, useCallback } from \"react\";\nimport { motion } from \"motion/react\";\n\n/**\n * Icon Pagination — Rauno Freiberg craft.\n *\n * Row of colored dot indicators. Mouse proximity triggers wave\n * effect — nearby dots lift and brighten with cosine falloff.\n * Active dot has accent ring and elevated shadow.\n * Click to navigate. Audio tick on page 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 IconPaginationProps {\n  totalPages?: number;\n  maxVisible?: number;\n  onChange?: (page: number) => void;\n  sound?: boolean;\n}\n\n/* ── CSS ── */\n\nconst CSS = `.ip{--ip-bg:rgba(255,255,255,.72);--ip-border:rgba(0,0,0,.06);--ip-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);--ip-ink:0,0,0}.dark .ip,[data-theme=\"dark\"] .ip{--ip-bg:rgba(30,30,32,.82);--ip-border:rgba(255,255,255,.06);--ip-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);--ip-ink:255,255,255}`;\n\n/* ── Constants ── */\n\nconst COLORS = [\n  \"#FF6B6B\",\n  \"#51CF66\",\n  \"#339AF0\",\n  \"#FCC419\",\n  \"#CC5DE8\",\n  \"#FF922B\",\n  \"#20C997\",\n  \"#F06595\",\n];\nconst PROX_RADIUS = 60;\nconst DOT_SIZE = 12;\nconst DOT_GAP = 10;\nconst SPRING = { type: \"spring\" as const, stiffness: 500, damping: 30 };\n\n/* ── Component ── */\n\nexport function IconPagination({\n  totalPages = 8,\n  maxVisible = 9,\n  onChange,\n  sound = true,\n}: IconPaginationProps) {\n  const [active, setActive] = useState(0);\n  const [hoverIdx, setHoverIdx] = useState(-1);\n  const lastSound = useRef(0);\n  const containerRef = useRef<HTMLDivElement>(null);\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 count = Math.min(totalPages, maxVisible);\n\n  // Visible window centered on active\n  const half = Math.floor(count / 2);\n  let start = active - half;\n  let end = active + (count - half - 1);\n  if (start < 0) {\n    end += -start;\n    start = 0;\n  }\n  if (end >= totalPages) {\n    start -= end - totalPages + 1;\n    end = totalPages - 1;\n    start = Math.max(0, start);\n  }\n\n  const visible: number[] = [];\n  for (let i = start; i <= end; i++) visible.push(i);\n\n  const onMouseMove = useCallback(\n    (e: React.MouseEvent) => {\n      const rect = containerRef.current?.getBoundingClientRect();\n      if (!rect) return;\n      const x = e.clientX - rect.left;\n      const dotW = DOT_SIZE + DOT_GAP;\n      const padL = 34; // arrow + padding\n      const idx = Math.round((x - padL - DOT_SIZE / 2) / dotW);\n      setHoverIdx(idx >= 0 && idx < visible.length ? idx : -1);\n    },\n    [visible.length],\n  );\n\n  return (\n    <div\n      className=\"ip\"\n      style={{\n        display: \"inline-flex\",\n        flexDirection: \"column\",\n        alignItems: \"center\",\n        gap: 8,\n      }}\n    >\n      <style dangerouslySetInnerHTML={{ __html: CSS }} />\n\n      <div\n        ref={containerRef}\n        onMouseMove={onMouseMove}\n        onMouseLeave={() => setHoverIdx(-1)}\n        style={{\n          display: \"inline-flex\",\n          alignItems: \"center\",\n          gap: DOT_GAP,\n          padding: \"10px 14px\",\n          background: \"var(--ip-bg)\",\n          border: \"1px solid var(--ip-border)\",\n          boxShadow: \"var(--ip-shadow)\",\n          borderRadius: 24,\n          backdropFilter: \"blur(16px)\",\n          WebkitBackdropFilter: \"blur(16px)\",\n        }}\n      >\n        {/* Prev arrow */}\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(--ip-ink),.6)`}\n              strokeWidth=\"1.3\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            />\n          </svg>\n        </button>\n\n        {visible.map((p, i) => {\n          const isActive = p === active;\n          const color = COLORS[p % COLORS.length];\n\n          // Proximity from hover\n          let lift = 0;\n          if (hoverIdx >= 0) {\n            const dist = Math.abs(i - hoverIdx);\n            if (dist * (DOT_SIZE + DOT_GAP) < PROX_RADIUS) {\n              lift =\n                (1 +\n                  Math.cos(\n                    ((dist * (DOT_SIZE + DOT_GAP)) / PROX_RADIUS) * Math.PI,\n                  )) /\n                2;\n            }\n          }\n\n          return (\n            <motion.button\n              key={p}\n              onClick={() => go(p)}\n              animate={{\n                y: -lift * 6,\n                scale: isActive ? 1.3 : 1 + lift * 0.1,\n              }}\n              transition={SPRING}\n              style={{\n                width: DOT_SIZE,\n                height: DOT_SIZE,\n                borderRadius: \"50%\",\n                border: \"none\",\n                padding: 0,\n                cursor: \"pointer\",\n                background: color,\n                opacity: isActive ? 1 : 0.45 + lift * 0.35,\n                boxShadow: isActive\n                  ? `0 0 0 2px var(--ip-bg), 0 0 0 3.5px ${color}, 0 2px 8px ${color}40`\n                  : lift > 0.3\n                    ? `0 2px 6px ${color}30`\n                    : \"none\",\n                transition: \"opacity .1s, box-shadow .15s\",\n                flexShrink: 0,\n              }}\n            />\n          );\n        })}\n\n        {/* Next arrow */}\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(--ip-ink),.6)`}\n              strokeWidth=\"1.3\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            />\n          </svg>\n        </button>\n      </div>\n\n      {/* Page label */}\n      <span\n        style={{\n          fontSize: 11,\n          fontWeight: 450,\n          color: `rgba(var(--ip-ink),.3)`,\n          fontVariantNumeric: \"tabular-nums\",\n          letterSpacing: \"0.02em\",\n        }}\n      >\n        Page {active + 1} of {totalPages}\n      </span>\n    </div>\n  );\n}\n\nexport default IconPagination;\n",
      "type": "registry:ui",
      "target": "components/ruixen/icon-pagination.tsx"
    }
  ]
}