{
  "$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<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\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<string | null>(null);\n  const [selectMode, setSelectMode] = useState(false);\n  const lastSound = useRef(0);\n  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);\n  const barRef = useRef<HTMLDivElement | null>(null);\n  const measured = useRef(false);\n  const selectTimer = useRef<ReturnType<typeof setTimeout>>();\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    <div className={`mt${className ? ` ${className}` : \"\"}`}>\n      <style dangerouslySetInnerHTML={{ __html: CSS }} />\n\n      {/* Tab bar */}\n      <div\n        ref={barRef}\n        style={{\n          position: \"relative\",\n          display: \"inline-flex\",\n          alignItems: \"center\",\n          padding: 4,\n          background: \"var(--mt-bg)\",\n          border: \"1px solid var(--mt-border)\",\n          boxShadow: \"var(--mt-shadow)\",\n          borderRadius: 14,\n          backdropFilter: \"blur(16px)\",\n          WebkitBackdropFilter: \"blur(16px)\",\n        }}\n        onMouseLeave={() => setHovered(null)}\n      >\n        {/* Pill indicator */}\n        <motion.div\n          style={{\n            position: \"absolute\",\n            top: 4,\n            left: 0,\n            height: \"calc(100% - 8px)\",\n            x: springX,\n            width: springW,\n            background: \"var(--mt-pill)\",\n            borderRadius: 10,\n            pointerEvents: \"none\",\n            zIndex: 0,\n          }}\n        />\n\n        {/* Tab buttons */}\n        {items.map((item, i) => (\n          <button\n            key={item.value}\n            ref={(el) => {\n              tabRefs.current[i] = el;\n            }}\n            onClick={() => go(item.value)}\n            onMouseEnter={() => {\n              setSelectMode(false);\n              clearTimeout(selectTimer.current);\n              setHovered(item.value);\n            }}\n            style={{\n              position: \"relative\",\n              zIndex: 1,\n              border: \"none\",\n              background: \"none\",\n              padding: \"8px 18px\",\n              fontSize: 14,\n              fontWeight: 500,\n              fontFamily: \"inherit\",\n              color:\n                active === item.value\n                  ? \"var(--mt-text-active)\"\n                  : \"var(--mt-text)\",\n              cursor: \"pointer\",\n              whiteSpace: \"nowrap\",\n              lineHeight: 1,\n              transition: \"color .15s ease\",\n              borderRadius: 10,\n            }}\n          >\n            {item.label}\n          </button>\n        ))}\n      </div>\n\n      {/* Content panel */}\n      {activeItem?.content != null && (\n        <div style={{ position: \"relative\", marginTop: 16, minHeight: 60 }}>\n          <AnimatePresence mode=\"wait\">\n            <motion.div\n              key={active}\n              initial={{ opacity: 0, y: 8 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, y: -6 }}\n              transition={CONTENT_SPRING}\n              style={{\n                padding: 20,\n                background: \"var(--mt-content-bg)\",\n                border: \"1px solid var(--mt-content-border)\",\n                borderRadius: 12,\n                color: \"var(--mt-text-active)\",\n                fontSize: 14,\n                lineHeight: 1.6,\n              }}\n            >\n              {activeItem.content}\n            </motion.div>\n          </AnimatePresence>\n        </div>\n      )}\n    </div>\n  );\n}\n\nexport default MagneticTabs;\n",
      "type": "registry:ui",
      "target": "components/ruixen/magnetic-tabs.tsx"
    }
  ]
}