Command Menu
Original · freecommand palette style menu with search
byWeb InnoventixReact + Tailwind
menucommandmenus
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/menu-command.jsonmenu-command.tsx
"use client";
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import type {
ChangeEvent,
JSX,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type IconName =
| "search"
| "grid"
| "folder"
| "inbox"
| "calendar"
| "doc"
| "user"
| "upload"
| "phone"
| "moon"
| "card"
| "bell"
| "book"
| "life"
| "keyboard";
type Command = {
id: string;
name: string;
category: string;
keywords: string[];
icon: IconName;
hint?: string[];
};
type Group = {
category: string;
items: Command[];
};
const CATEGORY_ORDER = ["Navigate", "Create", "Workspace", "Help"] as const;
const COMMANDS: Command[] = [
{
id: "nav-dashboard",
name: "Go to Dashboard",
category: "Navigate",
keywords: ["home", "overview", "metrics", "start"],
icon: "grid",
hint: ["G", "D"],
},
{
id: "nav-projects",
name: "Open Projects",
category: "Navigate",
keywords: ["repos", "work", "boards", "tasks"],
icon: "folder",
hint: ["G", "P"],
},
{
id: "nav-inbox",
name: "Jump to Inbox",
category: "Navigate",
keywords: ["messages", "notifications", "unread", "mail"],
icon: "inbox",
hint: ["G", "I"],
},
{
id: "nav-calendar",
name: "View Calendar",
category: "Navigate",
keywords: ["schedule", "events", "meetings", "agenda"],
icon: "calendar",
hint: ["G", "C"],
},
{
id: "create-doc",
name: "New Document",
category: "Create",
keywords: ["file", "page", "write", "note", "add"],
icon: "doc",
hint: ["⌘", "N"],
},
{
id: "create-invite",
name: "Invite Teammate",
category: "Create",
keywords: ["member", "add people", "collaborator", "share access"],
icon: "user",
},
{
id: "create-upload",
name: "Upload File",
category: "Create",
keywords: ["attach", "import", "media", "asset"],
icon: "upload",
hint: ["⌘", "U"],
},
{
id: "create-call",
name: "Start a Call",
category: "Create",
keywords: ["video", "meeting", "voice", "huddle"],
icon: "phone",
},
{
id: "ws-theme",
name: "Toggle Dark Mode",
category: "Workspace",
keywords: ["theme", "appearance", "light", "night"],
icon: "moon",
hint: ["⌘", "D"],
},
{
id: "ws-billing",
name: "Manage Billing",
category: "Workspace",
keywords: ["plan", "invoice", "subscription", "payment"],
icon: "card",
},
{
id: "ws-notify",
name: "Notification Preferences",
category: "Workspace",
keywords: ["alerts", "email", "push", "settings"],
icon: "bell",
},
{
id: "help-docs",
name: "Read Documentation",
category: "Help",
keywords: ["guide", "manual", "learn", "reference"],
icon: "book",
},
{
id: "help-support",
name: "Contact Support",
category: "Help",
keywords: ["help", "ticket", "chat", "assistance"],
icon: "life",
},
{
id: "help-shortcuts",
name: "Keyboard Shortcuts",
category: "Help",
keywords: ["keys", "hotkeys", "bindings", "cheatsheet"],
icon: "keyboard",
hint: ["⌘", "/"],
},
];
function Icon({ name }: { name: IconName }): JSX.Element {
const common = {
width: 18,
height: 18,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.7,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
};
switch (name) {
case "search":
return (
<svg {...common}>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.6-3.6" />
</svg>
);
case "grid":
return (
<svg {...common}>
<rect x="3" y="3" width="7" height="7" rx="1.5" />
<rect x="14" y="3" width="7" height="7" rx="1.5" />
<rect x="14" y="14" width="7" height="7" rx="1.5" />
<rect x="3" y="14" width="7" height="7" rx="1.5" />
</svg>
);
case "folder":
return (
<svg {...common}>
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
</svg>
);
case "inbox":
return (
<svg {...common}>
<path d="M4 5h16l1 8v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-3z" />
<path d="M3 13h5l1.5 2.5h5L21 13" />
</svg>
);
case "calendar":
return (
<svg {...common}>
<rect x="3" y="4.5" width="18" height="16.5" rx="2" />
<path d="M3 9.5h18M8 3v4M16 3v4" />
</svg>
);
case "doc":
return (
<svg {...common}>
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
<path d="M6 3h8l5 5v11a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z" />
<path d="M9 13h6M9 17h4" />
</svg>
);
case "user":
return (
<svg {...common}>
<circle cx="12" cy="8" r="4" />
<path d="M4.5 20.5a7.5 7.5 0 0 1 15 0" />
</svg>
);
case "upload":
return (
<svg {...common}>
<path d="M12 15V4M8 8l4-4 4 4" />
<path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" />
</svg>
);
case "phone":
return (
<svg {...common}>
<path d="M5 4h4l2 5-3 2a12 12 0 0 0 5 5l2-3 5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2z" />
</svg>
);
case "moon":
return (
<svg {...common}>
<path d="M20 14.2A8 8 0 0 1 9.8 4 8 8 0 1 0 20 14.2z" />
</svg>
);
case "card":
return (
<svg {...common}>
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="M3 10h18M7 15h4" />
</svg>
);
case "bell":
return (
<svg {...common}>
<path d="M6 9a6 6 0 0 1 12 0c0 5 2 6 2 6H4s2-1 2-6z" />
<path d="M10 20a2 2 0 0 0 4 0" />
</svg>
);
case "book":
return (
<svg {...common}>
<path d="M4 5a2 2 0 0 1 2-2h12v16H6a2 2 0 0 0-2 2z" />
<path d="M4 19a2 2 0 0 1 2-2h12" />
</svg>
);
case "life":
return (
<svg {...common}>
<circle cx="12" cy="12" r="9" />
<circle cx="12" cy="12" r="3.4" />
<path d="M5 5l4.6 4.6M14.4 14.4 19 19M19 5l-4.6 4.6M9.6 14.4 5 19" />
</svg>
);
case "keyboard":
return (
<svg {...common}>
<rect x="2" y="6" width="20" height="12" rx="2" />
<path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M8 14h8" />
</svg>
);
}
}
function Kbd({ children }: { children: string }): JSX.Element {
return (
<kbd className="inline-flex h-5 min-w-[20px] items-center justify-center rounded-md border border-slate-300 bg-slate-100 px-1.5 font-sans text-[11px] font-semibold text-slate-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400">
{children}
</kbd>
);
}
export default function MenuCommand(): JSX.Element {
const reduce = useReducedMotion();
const baseId = useId();
const listboxId = `${baseId}-listbox`;
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [activeId, setActiveId] = useState("");
const [lastRun, setLastRun] = useState<{ name: string; at: number } | null>(
null
);
const triggerRef = useRef<HTMLButtonElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const wasOpen = useRef(false);
const optionDomId = useCallback(
(id: string) => `${baseId}-opt-${id}`,
[baseId]
);
const groups = useMemo<Group[]>(() => {
const q = query.trim().toLowerCase();
const tokens = q.length > 0 ? q.split(/\s+/) : [];
const filtered =
tokens.length === 0
? COMMANDS
: COMMANDS.filter((c) => {
const hay = `${c.name} ${c.category} ${c.keywords.join(" ")}`.toLowerCase();
return tokens.every((t) => hay.includes(t));
});
return CATEGORY_ORDER.map((category) => ({
category,
items: filtered.filter((c) => c.category === category),
})).filter((g) => g.items.length > 0);
}, [query]);
const flat = useMemo<Command[]>(
() => groups.flatMap((g) => g.items),
[groups]
);
const runCommand = useCallback((cmd: Command) => {
setLastRun({ name: cmd.name, at: Date.now() });
setOpen(false);
}, []);
const move = useCallback(
(delta: number) => {
if (flat.length === 0) return;
const idx = flat.findIndex((c) => c.id === activeId);
const from = idx === -1 ? 0 : idx;
const next = (from + delta + flat.length) % flat.length;
setActiveId(flat[next].id);
},
[flat, activeId]
);
// Keep the active option valid as results change.
useEffect(() => {
if (flat.length === 0) {
setActiveId("");
return;
}
if (!flat.some((c) => c.id === activeId)) {
setActiveId(flat[0].id);
}
}, [flat, activeId]);
// Scroll the active option into view.
useEffect(() => {
if (!activeId) return;
itemRefs.current.get(activeId)?.scrollIntoView({ block: "nearest" });
}, [activeId]);
// Reset query on open, focus input on open, restore trigger focus on close.
useEffect(() => {
if (open) {
setQuery("");
const t = window.setTimeout(() => inputRef.current?.focus(), 20);
return () => window.clearTimeout(t);
}
if (wasOpen.current) {
triggerRef.current?.focus();
}
wasOpen.current = open;
return undefined;
}, [open]);
useEffect(() => {
wasOpen.current = open;
}, [open]);
// Lock body scroll while open.
useEffect(() => {
if (!open) return undefined;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
// Global command-palette hotkey.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((o) => !o);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const onInputKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault();
move(1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
move(-1);
} else if (e.key === "Home") {
e.preventDefault();
if (flat.length > 0) setActiveId(flat[0].id);
} else if (e.key === "End") {
e.preventDefault();
if (flat.length > 0) setActiveId(flat[flat.length - 1].id);
} else if (e.key === "Enter") {
e.preventDefault();
const cmd = flat.find((c) => c.id === activeId);
if (cmd) runCommand(cmd);
}
};
const onDialogKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
if (e.key === "Escape") {
e.preventDefault();
if (query.length > 0) {
setQuery("");
} else {
setOpen(false);
}
return;
}
if (e.key !== "Tab") return;
const root = panelRef.current;
if (!root) return;
const focusables = Array.from(
root.querySelectorAll<HTMLElement>(
'button, input, [href], [tabindex]:not([tabindex="-1"])'
)
).filter((el) => !el.hasAttribute("disabled"));
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
const current = document.activeElement;
if (e.shiftKey && current === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && current === last) {
e.preventDefault();
first.focus();
}
};
const onOverlayMouseDown = (e: ReactMouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) setOpen(false);
};
const panelInitial = reduce
? { opacity: 0 }
: { opacity: 0, scale: 0.96, y: 10 };
const panelAnimate = reduce
? { opacity: 1 }
: { opacity: 1, scale: 1, y: 0 };
const panelExit = reduce ? { opacity: 0 } : { opacity: 0, scale: 0.985, y: 6 };
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-6 py-24 text-slate-900 sm:py-32 dark:bg-slate-950 dark:text-slate-100">
{/* Ambient background */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-70"
style={{
backgroundImage:
"radial-gradient(60rem 40rem at 15% -10%, rgba(99,102,241,0.14), transparent 60%), radial-gradient(50rem 40rem at 110% 20%, rgba(139,92,246,0.12), transparent 55%)",
}}
/>
<div
aria-hidden
className="cmdk-anim pointer-events-none absolute -right-24 top-24 h-72 w-72 rounded-full bg-violet-400/10 blur-3xl dark:bg-violet-500/10"
style={{ animation: "cmdkfloat 12s ease-in-out infinite" }}
/>
<div className="relative mx-auto flex max-w-2xl flex-col items-start gap-8">
<div className="flex flex-col gap-3">
<span className="inline-flex w-fit items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-3 py-1 text-xs font-medium text-indigo-600 backdrop-blur dark:border-slate-800 dark:bg-slate-900/60 dark:text-indigo-300">
<span
className="cmdk-anim inline-block h-1.5 w-1.5 rounded-full bg-indigo-500"
style={{ animation: "cmdkpulse 2.4s ease-in-out infinite" }}
/>
Command Palette
</span>
<h2 className="text-balance text-3xl font-semibold tracking-tight sm:text-4xl">
Do anything, without leaving the keyboard.
</h2>
<p className="max-w-lg text-pretty text-sm leading-relaxed text-slate-500 dark:text-slate-400">
Search across navigation, creation, and workspace actions. Open it
anywhere with a shortcut, then drive it entirely with arrow keys and
Enter.
</p>
</div>
<button
ref={triggerRef}
type="button"
onClick={() => setOpen(true)}
aria-haspopup="dialog"
aria-expanded={open}
className="group flex w-full max-w-md items-center gap-3 rounded-xl border border-slate-200 bg-white px-4 py-3.5 text-left shadow-sm transition-colors hover:border-indigo-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:hover:border-indigo-600 dark:focus-visible:ring-offset-slate-950"
>
<span className="text-slate-400 transition-colors group-hover:text-indigo-500 dark:text-slate-500">
<Icon name="search" />
</span>
<span className="flex-1 text-sm text-slate-400 dark:text-slate-500">
Search commands, files, and actions…
</span>
<span className="flex items-center gap-1">
<Kbd>⌘</Kbd>
<Kbd>K</Kbd>
</span>
</button>
<div
role="status"
aria-live="polite"
className="min-h-[1.5rem] text-sm text-slate-500 dark:text-slate-400"
>
{lastRun ? (
<span>
Last run:{" "}
<span className="font-medium text-indigo-600 dark:text-indigo-300">
{lastRun.name}
</span>
</span>
) : (
<span className="text-slate-400 dark:text-slate-500">
Tip: press{" "}
<span className="font-medium text-slate-500 dark:text-slate-400">
⌘K
</span>{" "}
to open the palette.
</span>
)}
</div>
</div>
<AnimatePresence>
{open && (
<motion.div
key="cmdk-overlay"
className="fixed inset-0 z-50 flex items-start justify-center bg-slate-950/50 px-4 pt-[12vh] backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.16, ease: "easeOut" }}
onMouseDown={onOverlayMouseDown}
>
<motion.div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label="Command palette"
className="relative w-full max-w-xl overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl shadow-slate-900/20 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/50"
initial={panelInitial}
animate={panelAnimate}
exit={panelExit}
transition={{ duration: reduce ? 0 : 0.18, ease: "easeOut" }}
onKeyDown={onDialogKeyDown}
onMouseDown={(e) => e.stopPropagation()}
>
{/* Accent shimmer line */}
<span
aria-hidden
className="cmdk-anim absolute inset-x-0 top-0 h-px"
style={{
backgroundImage:
"linear-gradient(90deg, transparent, rgba(99,102,241,0.85), rgba(139,92,246,0.85), transparent)",
backgroundSize: "200% 100%",
animation: "cmdkshimmer 3.5s linear infinite",
}}
/>
{/* Search header */}
<div className="flex items-center gap-3 border-b border-slate-200 px-4 py-3.5 dark:border-slate-800">
<span className="text-indigo-500 dark:text-indigo-400">
<Icon name="search" />
</span>
<input
ref={inputRef}
type="text"
role="combobox"
aria-expanded
aria-controls={listboxId}
aria-activedescendant={
activeId ? optionDomId(activeId) : undefined
}
aria-autocomplete="list"
aria-label="Search commands"
autoComplete="off"
spellCheck={false}
value={query}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setQuery(e.target.value)
}
onKeyDown={onInputKeyDown}
placeholder="Search commands, files, and actions…"
className="w-full bg-transparent text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none dark:text-slate-100 dark:placeholder:text-slate-500"
/>
<span className="hidden shrink-0 items-center gap-1 text-[11px] text-slate-400 sm:flex dark:text-slate-500">
<Kbd>Esc</Kbd>
</span>
</div>
{/* Results */}
<div
id={listboxId}
role="listbox"
aria-label="Commands"
className="max-h-[min(60vh,380px)] overflow-y-auto overscroll-contain p-2"
>
{groups.length === 0 ? (
<div className="px-3 py-10 text-center">
<p className="text-sm font-medium text-slate-600 dark:text-slate-300">
No results for “{query.trim()}”
</p>
<p className="mt-1 text-xs text-slate-400 dark:text-slate-500">
Try a broader term like “new” or
“settings”.
</p>
</div>
) : (
groups.map((group) => {
const headingId = `${baseId}-grp-${group.category}`;
return (
<div
key={group.category}
role="group"
aria-labelledby={headingId}
className="mb-1 last:mb-0"
>
<div
id={headingId}
className="px-3 pb-1 pt-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500"
>
{group.category}
</div>
{group.items.map((cmd) => {
const active = cmd.id === activeId;
return (
<div
key={cmd.id}
id={optionDomId(cmd.id)}
role="option"
aria-selected={active}
ref={(el) => {
if (el) itemRefs.current.set(cmd.id, el);
else itemRefs.current.delete(cmd.id);
}}
onMouseMove={() => setActiveId(cmd.id)}
onClick={() => runCommand(cmd)}
className={[
"flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors",
active
? "bg-indigo-50 text-slate-900 dark:bg-indigo-500/15 dark:text-white"
: "text-slate-600 dark:text-slate-300",
].join(" ")}
>
<span
className={[
"flex h-8 w-8 shrink-0 items-center justify-center rounded-lg transition-colors",
active
? "bg-indigo-500 text-white"
: "bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400",
].join(" ")}
>
<Icon name={cmd.icon} />
</span>
<span className="flex-1 truncate font-medium">
{cmd.name}
</span>
{active && (
<span
aria-hidden
className="hidden items-center gap-1 text-indigo-500 sm:flex dark:text-indigo-300"
>
<Icon name="search" />
</span>
)}
{cmd.hint && cmd.hint.length > 0 && (
<span className="flex shrink-0 items-center gap-1">
{cmd.hint.map((k, i) => (
<Kbd key={`${cmd.id}-${i}`}>{k}</Kbd>
))}
</span>
)}
</div>
);
})}
</div>
);
})
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between gap-4 border-t border-slate-200 px-4 py-2.5 text-[11px] text-slate-400 dark:border-slate-800 dark:text-slate-500">
<div className="flex items-center gap-3">
<span className="flex items-center gap-1.5">
<Kbd>↑</Kbd>
<Kbd>↓</Kbd>
<span className="hidden sm:inline">navigate</span>
</span>
<span className="flex items-center gap-1.5">
<Kbd>↵</Kbd>
<span className="hidden sm:inline">select</span>
</span>
<span className="flex items-center gap-1.5">
<Kbd>Esc</Kbd>
<span className="hidden sm:inline">close</span>
</span>
</div>
<span className="tabular-nums">
{flat.length} {flat.length === 1 ? "result" : "results"}
</span>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<style>{`
@keyframes cmdkshimmer {
0% { background-position: 0% 50%; }
100% { background-position: 200% 50%; }
}
@keyframes cmdkpulse {
0%, 100% { opacity: 0.35; transform: scale(0.9); }
50% { opacity: 1; transform: scale(1.15); }
}
@keyframes cmdkfloat {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-16px); }
}
@media (prefers-reduced-motion: reduce) {
.cmdk-anim { animation: none !important; }
}
`}</style>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Dropdown Menu
Originalbasic dropdown menu with items
Dropdown Icons Menu
Originaldropdown with icons and shortcuts

User Menu
Originaluser account dropdown with avatar

Mega Menu
Originalmega menu with grouped links

Mega Cols Menu
Originalmulti-column mega menu with featured

Context Menu
Originalright-click context menu

Nested Menu
Originaldropdown with nested submenus

Select Menu
Originalcustom single select dropdown

Multiselect Menu
Originalmulti-select with checkable items

Actions Menu
Originalactions/kebab menu

Share Menu
Originalshare menu with options

Notifications Menu
Originalnotifications dropdown list

