{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "morphing-expandable-menu", "type": "registry:ui", "title": "Morphing Expandable Menu", "description": "A spring-animated pill that morphs into a dock, then expands into search, music player, timer, or note panels.", "dependencies": [ "motion", "lucide-react" ], "files": [ { "path": "registry/ruixenui/morphing-expandable-menu.tsx", "content": "\"use client\";\n\nimport { useState, useRef, useEffect } from \"react\";\nimport { motion, AnimatePresence } from \"motion/react\";\nimport {\n Search,\n Music2,\n Timer,\n Pen,\n X,\n Play,\n Pause,\n SkipForward,\n SkipBack,\n RotateCcw,\n Plus,\n} from \"lucide-react\";\n\ntype Mode = \"pill\" | \"dock\" | \"search\" | \"player\" | \"timer\" | \"note\";\n\nconst spring = {\n type: \"spring\" as const,\n damping: 28,\n stiffness: 380,\n mass: 0.7,\n};\n\nconst dockActions: { id: Mode; icon: typeof Search }[] = [\n { id: \"search\", icon: Search },\n { id: \"player\", icon: Music2 },\n { id: \"timer\", icon: Timer },\n { id: \"note\", icon: Pen },\n];\n\nconst dims: Record = {\n pill: [44, 44, 50],\n dock: [200, 44, 22],\n search: [280, 218, 18],\n player: [280, 172, 18],\n timer: [220, 180, 18],\n note: [256, 182, 18],\n};\n\nexport function MorphingExpandableMenu() {\n const [mode, setMode] = useState(\"pill\");\n const [hoveredDock, setHoveredDock] = useState(null);\n const ref = useRef(null);\n\n const [playing, setPlaying] = useState(false);\n const [progress] = useState(35);\n const [timeLeft, setTimeLeft] = useState(25 * 60);\n const [running, setRunning] = useState(false);\n const [noteText, setNoteText] = useState(\"\");\n const [query, setQuery] = useState(\"\");\n\n useEffect(() => {\n function onOutside(e: MouseEvent) {\n if (ref.current && !ref.current.contains(e.target as Node)) {\n setMode(\"pill\");\n setQuery(\"\");\n }\n }\n function onKey(e: KeyboardEvent) {\n if (e.key !== \"Escape\") return;\n if (mode === \"pill\") return;\n setMode(mode === \"dock\" ? \"pill\" : \"dock\");\n }\n document.addEventListener(\"mousedown\", onOutside);\n document.addEventListener(\"keydown\", onKey);\n return () => {\n document.removeEventListener(\"mousedown\", onOutside);\n document.removeEventListener(\"keydown\", onKey);\n };\n }, [mode]);\n\n useEffect(() => {\n if (!running) return;\n const id = setInterval(() => setTimeLeft((t) => Math.max(0, t - 1)), 1000);\n return () => clearInterval(id);\n }, [running]);\n\n useEffect(() => {\n if (mode !== \"dock\") setHoveredDock(null);\n }, [mode]);\n\n const fmt = (s: number) => {\n const m = Math.floor(s / 60);\n const sec = s % 60;\n return `${String(m).padStart(2, \"0\")}:${String(sec).padStart(2, \"0\")}`;\n };\n\n const goBack = () => setMode(\"dock\");\n const [w, h, r] = dims[mode];\n const isDockOrPill = mode === \"pill\" || mode === \"dock\";\n\n return (\n
\n \n {/* ── dock / pill bar ──────────────────────────── */}\n \n {isDockOrPill && (\n \n {dockActions.map((item, i) => (\n setMode(item.id)}\n onMouseEnter={() => setHoveredDock(item.id)}\n onMouseLeave={() => setHoveredDock(null)}\n animate={{\n width: mode === \"dock\" ? 36 : 0,\n marginLeft: mode === \"dock\" ? 3 : 0,\n opacity: mode === \"dock\" ? 1 : 0,\n }}\n transition={{\n ...spring,\n delay: mode === \"dock\" ? (3 - i) * 0.03 : 0,\n }}\n style={{\n height: 36,\n pointerEvents: mode === \"dock\" ? \"auto\" : \"none\",\n }}\n className=\"relative flex shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-xl text-muted-foreground transition-colors duration-150 hover:text-foreground\"\n >\n {hoveredDock === item.id && (\n \n )}\n \n \n ))}\n\n setMode(mode === \"pill\" ? \"dock\" : \"pill\")}\n className=\"ml-auto flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center\"\n whileTap={{ scale: 0.85 }}\n >\n \n \n \n \n \n )}\n \n\n {/* ── panels ───────────────────────────────────── */}\n \n {/* search */}\n {mode === \"search\" && (\n \n
\n \n setQuery(e.target.value)}\n placeholder=\"Search...\"\n className=\"flex-1 bg-transparent text-[13px] outline-none placeholder:text-muted-foreground/50\"\n />\n \n \n \n
\n
\n

\n Recent\n

\n
\n {[\n \"Project settings\",\n \"API documentation\",\n \"Team members\",\n \"Billing\",\n ].map((item, i) => (\n \n {item}\n \n ))}\n
\n
\n \n )}\n\n {/* player */}\n {mode === \"player\" && (\n \n
\n
\n
\n
\n

\n Midnight City\n

\n

\n M83 — Hurry Up, We're Dreaming\n

\n
\n
\n \n \n \n
\n\n
\n
\n \n
\n
\n 1:24\n 4:03\n
\n
\n\n
\n \n \n \n setPlaying(!playing)}\n className=\"flex size-9 cursor-pointer items-center justify-center rounded-full bg-foreground text-background\"\n >\n {playing ? (\n \n ) : (\n \n )}\n \n \n \n \n
\n \n )}\n\n {/* timer */}\n {mode === \"timer\" && (\n \n
\n

\n Focus\n

\n \n \n \n
\n\n

\n {fmt(timeLeft)}\n

\n\n
\n setRunning(!running)}\n className=\"h-8 cursor-pointer rounded-lg bg-foreground px-5 text-xs font-medium text-background\"\n >\n {running ? \"Pause\" : \"Start\"}\n \n {\n setRunning(false);\n setTimeLeft(25 * 60);\n }}\n className=\"flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-border/50 text-muted-foreground/60 hover:text-foreground\"\n >\n \n \n
\n \n )}\n\n {/* note */}\n {mode === \"note\" && (\n \n
\n

\n Quick Note\n

\n \n \n \n
\n setNoteText(e.target.value)}\n placeholder=\"Type something...\"\n className=\"flex-1 resize-none rounded-lg bg-foreground/[0.03] p-2.5 text-[13px] outline-none transition-colors placeholder:text-muted-foreground/40 focus:bg-foreground/[0.05]\"\n />\n
\n \n {noteText.length} chars\n \n \n Save\n \n
\n \n )}\n \n \n
\n );\n}\n", "type": "registry:ui", "target": "components/ruixen/morphing-expandable-menu.tsx" } ] }