{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "morphing-page-dots", "type": "registry:ui", "title": "Morphing Page Dots", "description": "Morphing pill dots with spring width animation, horizontal drag scrub, and audio ticks.", "dependencies": [ "motion" ], "files": [ { "path": "registry/ruixenui/morphing-page-dots.tsx", "content": "\"use client\";\n\nimport { useState, useRef, useCallback } from \"react\";\nimport { motion } from \"motion/react\";\n\n/**\n * Morphing Page Dots — Rauno Freiberg craft.\n *\n * Active dot stretches to pill shape showing page number.\n * Inactive dots are small circles. Spring width morph.\n * Horizontal drag to scrub through pages.\n * Glass container. 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 MorphingPageDotsProps {\n total?: number;\n initialPage?: number;\n onPageChange?: (page: number) => void;\n sound?: boolean;\n}\n\n/* ── CSS ── */\n\nconst CSS = `.mpd{--mpd-bg:rgba(255,255,255,.72);--mpd-border:rgba(0,0,0,.06);--mpd-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);--mpd-ink:0,0,0;--mpd-dot:rgba(0,0,0,.12);--mpd-active:rgba(0,0,0,.7)}.dark .mpd,[data-theme=\"dark\"] .mpd{--mpd-bg:rgba(30,30,32,.82);--mpd-border:rgba(255,255,255,.06);--mpd-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);--mpd-ink:255,255,255;--mpd-dot:rgba(255,255,255,.15);--mpd-active:rgba(255,255,255,.7)}.mpd-scrub{cursor:grab;touch-action:none}.mpd-scrub:active{cursor:grabbing}`;\n\n/* ── Constants ── */\n\nconst DOT_H = 8;\nconst DOT_W_INACTIVE = 8;\nconst DOT_W_ACTIVE = 28;\nconst DOT_GAP = 6;\nconst SPRING = { type: \"spring\" as const, stiffness: 500, 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 MorphingPageDots({\n total = 5,\n initialPage = 0,\n onPageChange,\n sound = true,\n}: MorphingPageDotsProps) {\n const [page, setPage] = useState(initialPage);\n const lastSound = useRef(0);\n const dragging = useRef(false);\n const startX = useRef(0);\n const startPage = useRef(0);\n\n const go = useCallback(\n (next: number) => {\n const c = clamp(next, 0, total - 1);\n if (c === page) return;\n if (sound) tick(lastSound);\n setPage(c);\n onPageChange?.(c);\n },\n [page, total, onPageChange, sound],\n );\n\n const onPointerDown = useCallback(\n (e: React.PointerEvent) => {\n (e.target as HTMLElement).setPointerCapture(e.pointerId);\n dragging.current = true;\n startX.current = e.clientX;\n startPage.current = page;\n },\n [page],\n );\n\n const onPointerMove = useCallback(\n (e: React.PointerEvent) => {\n if (!dragging.current) return;\n const dx = e.clientX - startX.current;\n const step = DOT_W_INACTIVE + DOT_GAP;\n const delta = Math.round(dx / step);\n go(startPage.current + delta);\n },\n [go],\n );\n\n const onPointerUp = useCallback(() => {\n dragging.current = false;\n }, []);\n\n return (\n \n