{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "magnetic-tabs", "type": "registry:ui", "title": "Magnetic Tabs", "description": "Glass pill indicator that springs between hovered tabs with magnetic attraction and snappier overshoot on selection.", "dependencies": [ "motion" ], "files": [ { "path": "registry/ruixenui/magnetic-tabs.tsx", "content": "\"use client\";\n\nimport { useState, useRef, useCallback, useEffect } from \"react\";\nimport {\n motion,\n AnimatePresence,\n useMotionValue,\n useSpring,\n} from \"motion/react\";\n\n/**\n * Magnetic Tabs — Rauno Freiberg craft.\n *\n * Pill indicator magnetically attracted to hovered tab.\n * Soft spring on hover, snappier overshoot on selection.\n * 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\nexport interface MagneticTabItem {\n value: string;\n label: string;\n content?: React.ReactNode;\n}\n\ninterface MagneticTabsProps {\n items?: MagneticTabItem[];\n defaultValue?: string;\n onChange?: (value: string) => void;\n sound?: boolean;\n className?: string;\n}\n\n/* ── CSS ── */\n\nconst CSS = `.mt{--mt-bg:rgba(255,255,255,.72);--mt-border:rgba(0,0,0,.06);--mt-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);--mt-pill:rgba(0,0,0,.06);--mt-text:rgba(0,0,0,.5);--mt-text-active:rgba(0,0,0,.9);--mt-content-bg:rgba(0,0,0,.02);--mt-content-border:rgba(0,0,0,.06)}.dark .mt,[data-theme=\"dark\"] .mt{--mt-bg:rgba(30,30,32,.82);--mt-border:rgba(255,255,255,.06);--mt-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);--mt-pill:rgba(255,255,255,.08);--mt-text:rgba(255,255,255,.45);--mt-text-active:rgba(255,255,255,.9);--mt-content-bg:rgba(255,255,255,.03);--mt-content-border:rgba(255,255,255,.06)}`;\n\n/* ── Constants ── */\n\nconst HOVER_SPRING = { type: \"spring\" as const, stiffness: 300, damping: 25 };\nconst SELECT_SPRING = { type: \"spring\" as const, stiffness: 500, damping: 22 };\nconst CONTENT_SPRING = {\n type: \"spring\" as const,\n stiffness: 250,\n damping: 25,\n};\n\n/* ── Component ── */\n\nexport function MagneticTabs({\n items = [\n {\n value: \"overview\",\n label: \"Overview\",\n content: \"Overview content here.\",\n },\n {\n value: \"activity\",\n label: \"Activity\",\n content: \"Activity content here.\",\n },\n {\n value: \"settings\",\n label: \"Settings\",\n content: \"Settings content here.\",\n },\n { value: \"faq\", label: \"FAQ\", content: \"FAQ content here.\" },\n ],\n defaultValue,\n onChange,\n sound = true,\n className,\n}: MagneticTabsProps) {\n const [active, setActive] = useState(defaultValue || items[0]?.value || \"\");\n const [hovered, setHovered] = useState(null);\n const [selectMode, setSelectMode] = useState(false);\n const lastSound = useRef(0);\n const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);\n const barRef = useRef(null);\n const measured = useRef(false);\n const selectTimer = useRef>();\n\n const pillX = useMotionValue(0);\n const pillW = useMotionValue(0);\n const springConfig = selectMode ? SELECT_SPRING : HOVER_SPRING;\n const springX = useSpring(pillX, springConfig);\n const springW = useSpring(pillW, springConfig);\n\n const movePill = useCallback(\n (value: string) => {\n const bar = barRef.current;\n if (!bar) return;\n const idx = items.findIndex((t) => t.value === value);\n const btn = tabRefs.current[idx];\n if (!btn) return;\n // Use offsetLeft/offsetWidth — immune to ancestor CSS transforms (e.g. scale)\n const x = btn.offsetLeft;\n const w = btn.offsetWidth;\n if (!measured.current) {\n pillX.jump(x);\n pillW.jump(w);\n measured.current = true;\n } else {\n pillX.set(x);\n pillW.set(w);\n }\n },\n [items, pillX, pillW],\n );\n\n useEffect(() => {\n movePill(hovered || active);\n const ro = new ResizeObserver(() => movePill(hovered || active));\n if (barRef.current) ro.observe(barRef.current);\n return () => ro.disconnect();\n }, [active, hovered, movePill]);\n\n const go = useCallback(\n (value: string) => {\n if (value === active) return;\n setSelectMode(true);\n if (sound) tick(lastSound);\n setActive(value);\n onChange?.(value);\n clearTimeout(selectTimer.current);\n selectTimer.current = setTimeout(() => setSelectMode(false), 300);\n },\n [active, onChange, sound],\n );\n\n const activeItem = items.find((t) => t.value === active);\n\n return (\n
\n