{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sliding-pagination",
  "type": "registry:ui",
  "title": "Sliding Pagination",
  "description": "Page numbers with sliding pill indicator, proximity brightness, wheel navigation, and spring physics.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/ruixenui/sliding-pagination.tsx",
      "content": "\"use client\";\n\nimport { useState, useRef, useCallback, useEffect } from \"react\";\nimport { motion } from \"motion/react\";\n\n/**\n * Sliding Pagination — Rauno Freiberg craft.\n *\n * Page numbers with a sliding background pill indicator.\n * layoutId spring animation between active positions.\n * Proximity-based brightness on numbers. Glass container.\n * Keyboard + wheel navigation. 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 SlidingPaginationProps {\n  totalPages?: number;\n  currentPage?: number;\n  maxVisiblePages?: number;\n  onPageChange?: (page: number) => void;\n  sound?: boolean;\n}\n\n/* ── CSS ── */\n\nconst CSS = `.slp{--slp-bg:rgba(255,255,255,.72);--slp-border:rgba(0,0,0,.06);--slp-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);--slp-ink:0,0,0;--slp-pill:rgba(0,0,0,.08)}.dark .slp,[data-theme=\"dark\"] .slp{--slp-bg:rgba(30,30,32,.82);--slp-border:rgba(255,255,255,.06);--slp-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);--slp-ink:255,255,255;--slp-pill:rgba(255,255,255,.1)}.slp-num{cursor:pointer;border:none;background:none;padding:0;position:relative;display:flex;align-items:center;justify-content:center;transition:color .1s}`;\n\n/* ── Constants ── */\n\nconst CELL = 32;\nconst SPRING = { type: \"spring\" as const, stiffness: 500, damping: 35 };\n\nfunction clamp(v: number, lo: number, hi: number) {\n  return Math.max(lo, Math.min(hi, v));\n}\n\n/* ── Component ── */\n\nexport function SlidingPagination({\n  totalPages = 20,\n  currentPage: extPage,\n  maxVisiblePages = 7,\n  onPageChange,\n  sound = true,\n}: SlidingPaginationProps) {\n  const [internal, setInternal] = useState(0);\n  const active = extPage !== undefined ? extPage - 1 : internal;\n  const lastSound = useRef(0);\n  const scrollAccum = useRef(0);\n  const containerRef = useRef<HTMLDivElement>(null);\n\n  const go = useCallback(\n    (next: number) => {\n      const c = clamp(next, 0, totalPages - 1);\n      if (c === active) return;\n      if (sound) tick(lastSound);\n      if (extPage !== undefined) {\n        onPageChange?.(c + 1);\n      } else {\n        setInternal(c);\n        onPageChange?.(c + 1);\n      }\n    },\n    [active, totalPages, onPageChange, sound, extPage],\n  );\n\n  // Wheel\n  useEffect(() => {\n    const el = containerRef.current;\n    if (!el) return;\n    const handler = (e: WheelEvent) => {\n      e.preventDefault();\n      scrollAccum.current += e.deltaY;\n      if (Math.abs(scrollAccum.current) >= 40) {\n        go(active + Math.sign(scrollAccum.current));\n        scrollAccum.current = 0;\n      }\n    };\n    el.addEventListener(\"wheel\", handler, { passive: false });\n    return () => el.removeEventListener(\"wheel\", handler);\n  }, [go, active]);\n\n  // Generate visible pages with ellipsis\n  const pages: (number | \"dots\")[] = [];\n  if (totalPages <= maxVisiblePages) {\n    for (let i = 0; i < totalPages; i++) pages.push(i);\n  } else {\n    const side = 1;\n    const mid = maxVisiblePages - 2 * side - 2;\n    let left = Math.max(active - Math.floor(mid / 2), side);\n    let right = Math.min(active + Math.floor(mid / 2), totalPages - 1 - side);\n    if (left <= side) right = Math.max(right, side + mid);\n    if (right >= totalPages - 1 - side)\n      left = Math.min(left, totalPages - 1 - side - mid);\n    left = Math.max(left, side);\n    right = Math.min(right, totalPages - 1 - side);\n\n    pages.push(0);\n    if (left > side) pages.push(\"dots\");\n    for (let i = left; i <= right; i++) pages.push(i);\n    if (right < totalPages - 1 - side) pages.push(\"dots\");\n    pages.push(totalPages - 1);\n  }\n\n  return (\n    <div\n      ref={containerRef}\n      className=\"slp\"\n      tabIndex={0}\n      onKeyDown={(e) => {\n        if (e.key === \"ArrowRight\") {\n          e.preventDefault();\n          go(active + 1);\n        }\n        if (e.key === \"ArrowLeft\") {\n          e.preventDefault();\n          go(active - 1);\n        }\n      }}\n      style={{\n        display: \"inline-flex\",\n        alignItems: \"center\",\n        gap: 2,\n        padding: \"4px 6px\",\n        background: \"var(--slp-bg)\",\n        border: \"1px solid var(--slp-border)\",\n        boxShadow: \"var(--slp-shadow)\",\n        borderRadius: 12,\n        backdropFilter: \"blur(16px)\",\n        WebkitBackdropFilter: \"blur(16px)\",\n        outline: \"none\",\n        userSelect: \"none\",\n      }}\n    >\n      <style dangerouslySetInnerHTML={{ __html: CSS }} />\n\n      {/* Prev */}\n      <button\n        className=\"slp-num\"\n        onClick={() => go(active - 1)}\n        style={{\n          width: CELL,\n          height: CELL,\n          opacity: active === 0 ? 0.15 : 0.4,\n        }}\n        disabled={active === 0}\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(--slp-ink),.6)`}\n            strokeWidth=\"1.3\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          />\n        </svg>\n      </button>\n\n      {/* Page numbers */}\n      {pages.map((p, i) => {\n        if (p === \"dots\") {\n          return (\n            <span\n              key={`d${i}`}\n              style={{\n                width: 20,\n                height: CELL,\n                display: \"flex\",\n                alignItems: \"center\",\n                justifyContent: \"center\",\n                fontSize: 11,\n                color: `rgba(var(--slp-ink),.2)`,\n                letterSpacing: \"0.1em\",\n              }}\n            >\n              ...\n            </span>\n          );\n        }\n\n        const isActive = p === active;\n        const dist = Math.abs(p - active);\n        const prox = Math.max(0, 1 - dist / 4);\n        const alpha = 0.25 + prox * 0.6;\n        const weight = Math.round(400 + prox * 160);\n\n        return (\n          <button\n            key={p}\n            className=\"slp-num\"\n            onClick={() => go(p)}\n            style={{\n              width: CELL,\n              height: CELL,\n              fontSize: 12,\n              fontWeight: weight,\n              color: `rgba(var(--slp-ink),${alpha})`,\n              fontVariantNumeric: \"tabular-nums\",\n              letterSpacing: \"-0.01em\",\n              zIndex: isActive ? 1 : 0,\n            }}\n          >\n            {/* Sliding pill indicator */}\n            {isActive && (\n              <motion.div\n                layoutId=\"slp-pill\"\n                transition={SPRING}\n                style={{\n                  position: \"absolute\",\n                  inset: 2,\n                  borderRadius: 8,\n                  background: \"var(--slp-pill)\",\n                }}\n              />\n            )}\n            <span style={{ position: \"relative\", zIndex: 1 }}>{p + 1}</span>\n          </button>\n        );\n      })}\n\n      {/* Next */}\n      <button\n        className=\"slp-num\"\n        onClick={() => go(active + 1)}\n        style={{\n          width: CELL,\n          height: CELL,\n          opacity: active === totalPages - 1 ? 0.15 : 0.4,\n        }}\n        disabled={active === totalPages - 1}\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(--slp-ink),.6)`}\n            strokeWidth=\"1.3\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          />\n        </svg>\n      </button>\n    </div>\n  );\n}\n\nexport default SlidingPagination;\n",
      "type": "registry:ui",
      "target": "components/ruixen/sliding-pagination.tsx"
    }
  ]
}