{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "invert-tabs", "type": "registry:ui", "title": "Invert Tabs", "description": "mix-blend-mode exclusion on a white pill — text auto-inverts in both light and dark themes. Spring physics. Audio tick.", "dependencies": [ "motion" ], "files": [ { "path": "registry/ruixenui/invert-tabs.tsx", "content": "\"use client\";\n\nimport { useState, useRef, useCallback, useEffect } from \"react\";\nimport { motion, useMotionValue, useSpring } from \"motion/react\";\n\n/**\n * Invert Tabs — inspired by rauno.me/craft.\n *\n * mix-blend-mode: exclusion on a white pill indicator.\n * Text auto-inverts under the pill in both light and dark themes.\n * Pure tab selector. 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) {\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 InvertTabItem {\n value: string;\n label: string;\n}\n\ninterface InvertTabsProps {\n items?: InvertTabItem[];\n defaultValue?: string;\n onChange?: (value: string) => void;\n sound?: boolean;\n className?: string;\n}\n\n/* ── CSS ── */\n\nconst CSS = `.it{--it-bg:#f0f0f0;--it-text:rgba(0,0,0,.5);--it-pill:#fff}.dark .it,[data-theme=\"dark\"] .it{--it-bg:#1a1a1c;--it-text:rgba(255,255,255,.45);--it-pill:#fff}`;\n\n/* ── Constants ── */\n\nconst SPRING = { stiffness: 420, damping: 28 };\n\n/* ── Component ── */\n\nexport function InvertTabs({\n items = [\n { value: \"all\", label: \"All Posts\" },\n { value: \"engineering\", label: \"Engineering\" },\n { value: \"community\", label: \"Community\" },\n { value: \"press\", label: \"Press\" },\n { value: \"changelog\", label: \"Changelog\" },\n ],\n defaultValue,\n onChange,\n sound = true,\n className,\n}: InvertTabsProps) {\n const [active, setActive] = useState(defaultValue || items[0]?.value || \"\");\n const lastSound = useRef(0);\n const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);\n const barRef = useRef(null);\n const measured = useRef(false);\n\n const pillX = useMotionValue(0);\n const pillW = useMotionValue(0);\n const springX = useSpring(pillX, SPRING);\n const springW = useSpring(pillW, SPRING);\n\n const measure = useCallback(() => {\n const bar = barRef.current;\n if (!bar) return;\n const idx = items.findIndex((t) => t.value === active);\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 }, [active, items, pillX, pillW]);\n\n useEffect(() => {\n measure();\n const ro = new ResizeObserver(measure);\n if (barRef.current) ro.observe(barRef.current);\n return () => ro.disconnect();\n }, [measure]);\n\n const go = useCallback(\n (value: string) => {\n if (value === active) return;\n if (sound) tick(lastSound);\n setActive(value);\n onChange?.(value);\n },\n [active, onChange, sound],\n );\n\n return (\n
\n