{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "floating-toolbar", "type": "registry:ui", "title": "Floating Toolbar", "description": "A floating toolbar with animated tab switching, cursor glow, and morphing search input.", "dependencies": [ "motion" ], "files": [ { "path": "registry/ruixenui/floating-toolbar.tsx", "content": "\"use client\";\n\nimport { motion, AnimatePresence } from \"motion/react\";\nimport { useState, useRef, useEffect } from \"react\";\n\n/**\n * Floating Toolbar — Rauno Freiberg craft.\n *\n * One surface. Underline tracks active. Hover follows cursor.\n * Container breathes via layout. Cursor glow under glass.\n * Staggered choreography. Per-role springs.\n */\n\n/* ── Springs — tuned per visual weight ── */\n\nconst spring = {\n shell: { type: \"spring\" as const, stiffness: 420, damping: 34 },\n line: { type: \"spring\" as const, stiffness: 500, damping: 32, mass: 0.8 },\n hover: { type: \"spring\" as const, stiffness: 400, damping: 28 },\n content: { type: \"spring\" as const, stiffness: 340, damping: 28 },\n press: { type: \"spring\" as const, stiffness: 600, damping: 32 },\n} as const;\n\n/* ── Types ── */\n\ninterface Tab {\n id: string;\n label: string;\n}\n\ninterface FloatingToolbarProps {\n tabs?: Tab[];\n onTabChange?: (id: string) => void;\n onSearch?: (query: string) => void;\n}\n\nconst DEFAULT_TABS: Tab[] = [\n { id: \"all\", label: \"All\" },\n { id: \"recent\", label: \"Recent\" },\n { id: \"starred\", label: \"Starred\" },\n];\n\n/* ── Component ── */\n\nexport function FloatingToolbar({\n tabs = DEFAULT_TABS,\n onTabChange,\n onSearch,\n}: FloatingToolbarProps) {\n const [active, setActive] = useState(tabs[0]?.id ?? \"\");\n const [hovered, setHovered] = useState(null);\n const [mode, setMode] = useState<\"browse\" | \"search\">(\"browse\");\n const [query, setQuery] = useState(\"\");\n const inputRef = useRef(null);\n\n /* Cursor glow tracking */\n const [glowX, setGlowX] = useState(0);\n const [glowOn, setGlowOn] = useState(false);\n\n /* Focus input on search open */\n useEffect(() => {\n if (mode === \"search\") {\n const t = setTimeout(() => inputRef.current?.focus(), 80);\n return () => clearTimeout(t);\n }\n }, [mode]);\n\n /* Keyboard: ⌘K opens, Esc closes */\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => {\n if ((e.metaKey || e.ctrlKey) && e.key === \"k\") {\n e.preventDefault();\n setMode(\"search\");\n }\n if (e.key === \"Escape\" && mode === \"search\") dismiss();\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [mode]);\n\n const select = (id: string) => {\n setActive(id);\n onTabChange?.(id);\n };\n\n const dismiss = () => {\n setMode(\"browse\");\n setQuery(\"\");\n onSearch?.(\"\");\n };\n\n const searching = mode === \"search\";\n\n return (\n
\n {/* One surface — layout-animated shell */}\n {\n const r = e.currentTarget.getBoundingClientRect();\n setGlowX(e.clientX - r.left);\n if (!glowOn) setGlowOn(true);\n }}\n onMouseLeave={() => {\n setGlowOn(false);\n setHovered(null);\n }}\n >\n {/* ── Cursor glow — light-under-glass ── */}\n \n\n \n {!searching ? (\n /* ────────────────────────\n BROWSE — tabs + search\n ──────────────────────── */\n \n
\n {tabs.map((tab, i) => (\n select(tab.id)}\n onMouseEnter={() => setHovered(tab.id)}\n initial={{ opacity: 0, y: 4 }}\n animate={{ opacity: 1, y: 0 }}\n exit={{ opacity: 0, y: -3 }}\n whileTap={{ scale: 0.96 }}\n transition={{ ...spring.content, delay: i * 0.03 }}\n className={`relative px-3 py-1.5 text-[13px] font-medium tracking-[-0.01em] outline-none cursor-pointer select-none transition-colors duration-200 ${\n active === tab.id\n ? \"text-white\"\n : \"text-neutral-500 hover:text-neutral-300\"\n }`}\n >\n {/* Hover follower — slides between tabs */}\n {hovered === tab.id && (\n \n )}\n\n {/* Active underline — inherently centered, no measurement */}\n {active === tab.id && (\n \n )}\n\n {tab.label}\n \n ))}\n
\n\n {/* Divider */}\n
\n\n {/* Search trigger */}\n setMode(\"search\")}\n whileHover={{ scale: 1.08 }}\n whileTap={{ scale: 0.88 }}\n transition={spring.press}\n initial={{ opacity: 0 }}\n animate={{ opacity: 1 }}\n exit={{ opacity: 0 }}\n className=\"flex items-center justify-center w-7 h-7 mr-1 rounded-lg text-neutral-500 hover:text-neutral-300 hover:bg-white/[0.04] transition-colors duration-200 outline-none cursor-pointer\"\n aria-label=\"Search\"\n >\n \n \n \n ) : (\n /* ────────────────────────\n SEARCH — input + esc\n ──────────────────────── */\n \n {/* Icon — static, quiet */}\n \n \n \n\n {/* Input */}\n \n {\n setQuery(e.target.value);\n onSearch?.(e.target.value);\n }}\n placeholder=\"Search…\"\n className=\"w-full bg-transparent outline-none text-[13px] font-medium tracking-[-0.01em] text-white placeholder:text-neutral-600 caret-neutral-500\"\n />\n \n\n {/* Esc — whisper-quiet, earns attention on hover */}\n \n esc\n \n \n )}\n \n \n
\n );\n}\n\n/* ── Icon ── */\n\nfunction SearchIcon() {\n return (\n \n \n \n \n );\n}\n\nexport default FloatingToolbar;\n", "type": "registry:ui", "target": "components/ruixen/floating-toolbar.tsx" } ] }