{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "wheel-pagination", "type": "registry:ui", "title": "Wheel Pagination", "description": "Vertical drum pagination with scroll wheel navigation, proximity brightness, and spring physics.", "dependencies": [ "motion" ], "registryDependencies": [], "files": [ { "path": "registry/ruixenui/wheel-pagination.tsx", "content": "\"use client\";\n\nimport { useState, useRef, useCallback, useEffect } from \"react\";\nimport { motion, AnimatePresence } from \"motion/react\";\n\n/**\n * Wheel Pagination — Rauno Freiberg craft.\n *\n * Visible window of page numbers in a glass container.\n * Scroll wheel or drag vertically to rotate through pages.\n * Active number at center — full brightness, heavier weight.\n * Adjacent numbers fade with proximity. Spring snap to integers.\n * Micro noise-burst on every detent crossing.\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 WheelPaginationProps {\n totalPages?: number;\n visibleCount?: number;\n onChange?: (page: number) => void;\n sound?: boolean;\n}\n\n/* ── CSS ── */\n\nconst CSS = `.wp{--wp-bg:rgba(255,255,255,.72);--wp-border:rgba(0,0,0,.06);--wp-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);--wp-ink:0,0,0}.dark .wp,[data-theme=\"dark\"] .wp{--wp-bg:rgba(30,30,32,.82);--wp-border:rgba(255,255,255,.06);--wp-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);--wp-ink:255,255,255}`;\n\n/* ── Constants ── */\n\nconst ITEM_H = 36;\nconst SPRING = { type: \"spring\" as const, stiffness: 400, damping: 32 };\n\nfunction clamp(v: number, lo: number, hi: number) {\n return Math.max(lo, Math.min(hi, v));\n}\n\n/* ── Arrow SVG ── */\n\nfunction Arrow({\n dir,\n disabled,\n onClick,\n}: {\n dir: \"up\" | \"down\";\n disabled: boolean;\n onClick: () => void;\n}) {\n return (\n {\n if (!disabled) (e.currentTarget as HTMLElement).style.opacity = \"0.7\";\n }}\n onMouseLeave={(e) => {\n (e.currentTarget as HTMLElement).style.opacity = disabled\n ? \"0.2\"\n : \"0.4\";\n }}\n >\n \n \n \n \n );\n}\n\n/* ── Component ── */\n\nexport function WheelPagination({\n totalPages = 50,\n visibleCount = 5,\n onChange,\n sound = true,\n}: WheelPaginationProps) {\n const [active, setActive] = useState(0);\n const lastSound = useRef(0);\n const scrollAccum = useRef(0);\n const containerRef = useRef(null);\n const prevPage = useRef(0);\n\n const go = useCallback(\n (next: number) => {\n const c = clamp(next, 0, totalPages - 1);\n if (c !== prevPage.current && sound) tick(lastSound);\n prevPage.current = c;\n setActive(c);\n onChange?.(c);\n },\n [totalPages, onChange, sound],\n );\n\n // Wheel handler\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(prevPage.current + 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]);\n\n useEffect(() => {\n prevPage.current = active;\n }, [active]);\n\n // Visible window\n const half = Math.floor(visibleCount / 2);\n let start = active - half;\n let end = active + half;\n if (start < 0) {\n end += -start;\n start = 0;\n }\n if (end > totalPages - 1) {\n start -= end - (totalPages - 1);\n end = totalPages - 1;\n start = Math.max(0, start);\n }\n const visible: number[] = [];\n for (let i = start; i <= end; i++) visible.push(i);\n\n const viewH = visibleCount * ITEM_H;\n\n return (\n \n