All componentsoverlay
Command Menu
Press Ctrl/Cmd+K anywhere on this page, or click the button below.
Last selected: none
Installation
$ npx shadcn@latest add https://deepmoon.dev/r/command-menu.jsonAlso installs: kbd
npm dependencies: lucide-react
Props
CommandMenuProps
| Prop | Type | Required |
|---|---|---|
| items | CommandMenuItem[] | Yes |
| placeholder | string | No |
| emptyMessage | string | No |
| shortcut | boolean | No |
Code
command-menu.tsx
"use client";
import * as React from "react";
import { Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { Kbd } from "@/components/kbd";
export interface CommandMenuItem {
id: string;
label: string;
group?: string;
icon?: React.ReactNode;
shortcut?: string[];
onSelect: () => void;
}
export interface CommandMenuProps {
items: CommandMenuItem[];
placeholder?: string;
emptyMessage?: string;
/** Set false to disable the built-in global Cmd/Ctrl+K listener. */
shortcut?: boolean;
}
function getFocusable(container: HTMLElement): HTMLElement[] {
return Array.from(
container.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])',
),
).filter((el) => el.offsetParent !== null);
}
/**
* Dialog (role="dialog", focus trap, Escape/backdrop close — same shape as
* Navbar's mobile drawer) containing a combobox (role="combobox" input +
* role="listbox" popup with aria-activedescendant roving — same shape as
* Multiselect's popup), grouped visually but with one flat keyboard index
* across all visible items regardless of group boundaries.
*/
export function CommandMenu({
items,
placeholder = "Type a command or search...",
emptyMessage = "No results found.",
shortcut = true,
}: CommandMenuProps) {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState("");
const [activeIndex, setActiveIndex] = React.useState(0);
const inputRef = React.useRef<HTMLInputElement>(null);
const panelRef = React.useRef<HTMLDivElement>(null);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const baseId = React.useId();
const listboxId = `${baseId}-listbox`;
const filtered = items.filter((item) => item.label.toLowerCase().includes(search.trim().toLowerCase()));
const groups: { name: string | undefined; items: CommandMenuItem[] }[] = [];
for (const item of filtered) {
const last = groups[groups.length - 1];
if (last && last.name === item.group) last.items.push(item);
else groups.push({ name: item.group, items: [item] });
}
React.useEffect(() => {
if (!shortcut) return;
function handleKeyDown(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
setOpen((o) => !o);
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [shortcut]);
React.useEffect(() => {
setActiveIndex(0);
}, [search, open]);
React.useEffect(() => {
if (open) {
document.body.style.overflow = "hidden";
requestAnimationFrame(() => inputRef.current?.focus());
} else {
document.body.style.overflow = "";
setSearch("");
// The trigger button unmounts while open and remounts once `open`
// flips back to false, so a captured document.activeElement
// reference would go stale — triggerRef always points at whichever
// instance is currently in the DOM, including the freshly remounted
// one, since refs are assigned during commit, before this effect runs.
triggerRef.current?.focus();
}
return () => {
document.body.style.overflow = "";
};
}, [open]);
React.useEffect(() => {
if (!open) return;
function handleKeyDown(event: KeyboardEvent) {
if (event.key !== "Tab" || !panelRef.current) return;
const focusables = getFocusable(panelRef.current);
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open]);
const selectItem = (item: CommandMenuItem) => {
setOpen(false);
item.onSelect();
};
const activeItem = filtered[activeIndex];
return (
<>
{!open ? (
<button
ref={triggerRef}
type="button"
onClick={() => setOpen(true)}
className="flex items-center gap-2 rounded-md border border-input bg-background px-3 py-1.5 text-sm text-muted-foreground outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
>
<Search className="size-4" aria-hidden="true" />
Search
{shortcut ? <Kbd keys={["Ctrl", "K"]} className="ml-4" /> : null}
</button>
) : null}
{open ? (
<div className="fixed inset-0 z-50 flex items-start justify-center px-4 pt-[15vh]">
<div aria-hidden="true" className="absolute inset-0 bg-foreground/20" onClick={() => setOpen(false)} />
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label="Command menu"
className="relative flex w-full max-w-lg flex-col overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-lg"
>
<div className="flex items-center gap-2 border-b border-border px-3">
<Search className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<input
ref={inputRef}
role="combobox"
aria-expanded="true"
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={activeItem ? `${baseId}-option-${activeItem.id}` : undefined}
aria-label="Search commands"
value={search}
onChange={(event) => setSearch(event.target.value)}
onKeyDown={(event) => {
if (event.key === "ArrowDown") {
event.preventDefault();
setActiveIndex((i) => (filtered.length === 0 ? 0 : (i + 1) % filtered.length));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setActiveIndex((i) => (filtered.length === 0 ? 0 : (i - 1 + filtered.length) % filtered.length));
} else if (event.key === "Enter") {
event.preventDefault();
if (activeItem) selectItem(activeItem);
} else if (event.key === "Escape") {
setOpen(false);
}
}}
placeholder={placeholder}
className="h-11 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
/>
</div>
<ul id={listboxId} role="listbox" aria-label="Commands" className="max-h-80 overflow-y-auto p-1">
{filtered.length === 0 ? (
<li className="px-2 py-6 text-center text-sm text-muted-foreground">{emptyMessage}</li>
) : (
groups.map((group, groupIndex) => (
<React.Fragment key={group.name ?? `group-${groupIndex}`}>
{group.name ? (
<li role="presentation" className="px-2 pb-1 pt-2 text-xs font-medium text-muted-foreground first:pt-1">
{group.name}
</li>
) : null}
{group.items.map((item) => {
const index = filtered.indexOf(item);
return (
<li
key={item.id}
id={`${baseId}-option-${item.id}`}
role="option"
aria-selected={index === activeIndex}
onMouseEnter={() => setActiveIndex(index)}
onMouseDown={(event) => {
event.preventDefault();
selectItem(item);
}}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm",
index === activeIndex && "bg-accent text-accent-foreground",
)}
>
{item.icon ? (
<span className="text-muted-foreground" aria-hidden="true">
{item.icon}
</span>
) : null}
<span className="flex-1">{item.label}</span>
{item.shortcut ? <Kbd keys={item.shortcut} /> : null}
</li>
);
})}
</React.Fragment>
))
)}
</ul>
</div>
</div>
) : null}
</>
);
}