{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "stack-pagination", "type": "registry:ui", "title": "Stack Pagination", "description": "Physical card stack pagination with depth compression, diminishing scale, and spring physics.", "dependencies": [ "motion" ], "registryDependencies": [], "files": [ { "path": "registry/ruixenui/stack-pagination.tsx", "content": "\"use client\";\n\nimport { useState, useRef, useCallback } from \"react\";\nimport { motion, AnimatePresence } from \"motion/react\";\n\n/**\n * Stack Pagination — Rauno Freiberg craft.\n *\n * Physical card stack with depth compression. Active card is full\n * opacity on top. Cards behind compress with diminishing scale,\n * progressive blur, and fading opacity. Click to advance.\n * Arrow keys navigate. 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 StackPaginationProps {\n totalPages?: number;\n onChange?: (page: number) => void;\n sound?: boolean;\n}\n\n/* ── CSS ── */\n\nconst CSS = `.skp{--skp-bg:rgba(255,255,255,.72);--skp-card:rgba(255,255,255,.85);--skp-border:rgba(0,0,0,.06);--skp-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);--skp-ink:0,0,0}.dark .skp,[data-theme=\"dark\"] .skp{--skp-bg:rgba(30,30,32,.82);--skp-card:rgba(40,40,44,.85);--skp-border:rgba(255,255,255,.06);--skp-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);--skp-ink:255,255,255}`;\n\n/* ── Constants ── */\n\nconst CARD_W = 80;\nconst CARD_H = 100;\nconst MAX_VISIBLE = 5;\nconst DEPTH_Y = 6;\nconst DEPTH_SCALE = 0.04;\nconst DEPTH_OPACITY = 0.15;\nconst SPRING = { type: \"spring\" as const, stiffness: 400, damping: 30 };\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 StackPagination({\n totalPages = 10,\n onChange,\n sound = true,\n}: StackPaginationProps) {\n const [active, setActive] = useState(0);\n const lastSound = useRef(0);\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 setActive(c);\n onChange?.(c);\n },\n [active, totalPages, onChange, sound],\n );\n\n const visible: number[] = [];\n for (let i = 0; i < Math.min(MAX_VISIBLE, totalPages); i++) {\n const page = active + i;\n if (page < totalPages) visible.push(page);\n }\n\n const stackH = CARD_H + (visible.length - 1) * DEPTH_Y;\n\n return (\n {\n if (e.key === \"ArrowRight\" || e.key === \"ArrowDown\") {\n e.preventDefault();\n go(active + 1);\n }\n if (e.key === \"ArrowLeft\" || e.key === \"ArrowUp\") {\n e.preventDefault();\n go(active - 1);\n }\n }}\n style={{\n display: \"inline-flex\",\n flexDirection: \"column\",\n alignItems: \"center\",\n gap: 16,\n outline: \"none\",\n }}\n >\n