{
  "$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<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 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    <div\n      className=\"skp\"\n      tabIndex={0}\n      onKeyDown={(e) => {\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      <style dangerouslySetInnerHTML={{ __html: CSS }} />\n\n      <div\n        style={{\n          position: \"relative\",\n          width: CARD_W,\n          height: stackH,\n          transition: \"height .3s cubic-bezier(.4,0,.2,1)\",\n        }}\n      >\n        <AnimatePresence>\n          {visible.map((page, index) => {\n            const isTop = index === 0;\n            return (\n              <motion.div\n                key={page}\n                initial={{ opacity: 0, y: -20, scale: 0.9 }}\n                animate={{\n                  opacity: 1 - index * DEPTH_OPACITY,\n                  y: index * DEPTH_Y,\n                  scale: 1 - index * DEPTH_SCALE,\n                }}\n                exit={{\n                  opacity: 0,\n                  x: 120,\n                  rotate: 8,\n                  transition: { ...SPRING, opacity: { duration: 0.15 } },\n                }}\n                transition={SPRING}\n                onClick={() => (isTop ? go(active + 1) : go(page))}\n                style={{\n                  position: \"absolute\",\n                  top: 0,\n                  left: 0,\n                  width: CARD_W,\n                  height: CARD_H,\n                  zIndex: visible.length - index,\n                  borderRadius: 12,\n                  background: \"var(--skp-card)\",\n                  border: \"1px solid var(--skp-border)\",\n                  boxShadow: isTop\n                    ? \"0 2px 8px rgba(0,0,0,.06), 0 8px 24px rgba(0,0,0,.04)\"\n                    : \"var(--skp-shadow)\",\n                  backdropFilter: \"blur(12px)\",\n                  WebkitBackdropFilter: \"blur(12px)\",\n                  display: \"flex\",\n                  flexDirection: \"column\",\n                  alignItems: \"center\",\n                  justifyContent: \"center\",\n                  gap: 4,\n                  cursor: \"pointer\",\n                  filter: `blur(${index * 0.4}px)`,\n                  userSelect: \"none\",\n                }}\n              >\n                <span\n                  style={{\n                    fontSize: 22,\n                    fontWeight: 600,\n                    color: `rgba(var(--skp-ink),${isTop ? 0.85 : 0.3})`,\n                    fontVariantNumeric: \"tabular-nums\",\n                    letterSpacing: \"-0.02em\",\n                    transition: \"color .12s\",\n                  }}\n                >\n                  {page + 1}\n                </span>\n                {isTop && (\n                  <span\n                    style={{\n                      fontSize: 10,\n                      fontWeight: 400,\n                      color: `rgba(var(--skp-ink),.3)`,\n                      letterSpacing: \"0.02em\",\n                    }}\n                  >\n                    of {totalPages}\n                  </span>\n                )}\n              </motion.div>\n            );\n          })}\n        </AnimatePresence>\n      </div>\n\n      <div style={{ display: \"flex\", alignItems: \"center\", gap: 8 }}>\n        <button\n          onClick={() => go(active - 1)}\n          style={{\n            border: \"none\",\n            background: `rgba(var(--skp-ink),.04)`,\n            padding: \"6px 12px\",\n            borderRadius: 8,\n            cursor: active === 0 ? \"default\" : \"pointer\",\n            opacity: active === 0 ? 0.3 : 1,\n            fontSize: 11,\n            fontWeight: 500,\n            color: `rgba(var(--skp-ink),.55)`,\n            transition: \"opacity .12s, background .12s\",\n          }}\n          onMouseEnter={(e) => {\n            if (active > 0)\n              (e.currentTarget as HTMLElement).style.background =\n                `rgba(var(--skp-ink),.08)`;\n          }}\n          onMouseLeave={(e) => {\n            (e.currentTarget as HTMLElement).style.background =\n              `rgba(var(--skp-ink),.04)`;\n          }}\n        >\n          Prev\n        </button>\n\n        <span\n          style={{\n            fontSize: 11,\n            fontWeight: 450,\n            color: `rgba(var(--skp-ink),.3)`,\n            fontVariantNumeric: \"tabular-nums\",\n            minWidth: 36,\n            textAlign: \"center\",\n          }}\n        >\n          {active + 1} / {totalPages}\n        </span>\n\n        <button\n          onClick={() => go(active + 1)}\n          style={{\n            border: \"none\",\n            background: `rgba(var(--skp-ink),.04)`,\n            padding: \"6px 12px\",\n            borderRadius: 8,\n            cursor: active === totalPages - 1 ? \"default\" : \"pointer\",\n            opacity: active === totalPages - 1 ? 0.3 : 1,\n            fontSize: 11,\n            fontWeight: 500,\n            color: `rgba(var(--skp-ink),.55)`,\n            transition: \"opacity .12s, background .12s\",\n          }}\n          onMouseEnter={(e) => {\n            if (active < totalPages - 1)\n              (e.currentTarget as HTMLElement).style.background =\n                `rgba(var(--skp-ink),.08)`;\n          }}\n          onMouseLeave={(e) => {\n            (e.currentTarget as HTMLElement).style.background =\n              `rgba(var(--skp-ink),.04)`;\n          }}\n        >\n          Next\n        </button>\n      </div>\n    </div>\n  );\n}\n\nexport default StackPagination;\n",
      "type": "registry:ui",
      "target": "components/ruixen/stack-pagination.tsx"
    }
  ]
}