{ "$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) {\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(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 {\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