Command Menu
A searchable command palette — filtered, grouped items with keyboard navigation, optionally inside a ⌘K dialog.
- Status
- Since
0.1.0- Accessibility pattern
- APG combobox (listbox popup)
Last updated
Install
Add Command Menu from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.
pnpm dlx shadcn@latest add @vegastack/commandThe same command installs the registry items it composes: @vegastack/dialog, @vegastack/floating-surface.
Usage
import {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command";
const groups = [
{
heading: "Suggestions",
items: [
{ value: "calendar", label: "Calendar" },
{ value: "search", label: "Search" },
],
},
{
heading: "Settings",
items: [{ value: "profile", label: "Profile", shortcut: "⌘P" }],
},
];
function Groups() {
const filtered = useCommandFilteredItems<(typeof groups)[number]>();
return filtered.map((group, i) => (
<React.Fragment key={group.heading}>
{i > 0 ? <CommandSeparator /> : null}
<CommandGroup heading={group.heading} items={group.items}>
{(item) => (
<CommandItem
key={item.value}
value={item.value}
onSelect={(value) => router.push(`/${value}`)}
>
{item.label}
{item.shortcut ? (
<CommandShortcut>{item.shortcut}</CommandShortcut>
) : null}
</CommandItem>
)}
</CommandGroup>
</React.Fragment>
));
}
<Command items={groups}>
<CommandInput placeholder="Type a command or search…" />
<CommandEmpty>No results found.</CommandEmpty>
<CommandList>
<Groups />
</CommandList>
</Command>;Presentational.
Commandhandles search filtering, grouping, and keyboard navigation; eachCommandItemexposes anonSelectcallback. Wiring those callbacks to routes or actions — and binding the ⌘K shortcut that opensCommandDialog— is the consuming app's responsibility.Data-driven.
Commandis built on Base UI'sCombobox, rendered always-open in itsinlinemode (the list is part of the normal layout flow, never a floating popup). Base UI only filters — and drivesCommandEmpty— off a query-filtereditemsarray on the root; staticCommandItemchildren never narrow. PassitemstoCommand(a flat array, or an array of{ heading, items }groups) and render with a function child onCommandList(flat) oruseCommandFilteredItems+CommandGroup(grouped) — see Anatomy.
Anatomy
Command is a compound component built on Base UI's Combobox. Compose the parts inside the root:
Anatomy change
CommandEmpty and CommandLoading render role="status", which is not a
valid child of CommandList's role="listbox" (ARIA only permits
option/group). Render them as siblings of CommandList, not its
children.
<Command items={items}>
<CommandInput placeholder="Search…" />
{loading ? (
<CommandLoading>
<Spinner size="inherit" label="" />
Fetching commands…
</CommandLoading>
) : null}
<CommandEmpty>No results found.</CommandEmpty>
<CommandList>
{(item) => (
<CommandItem key={item.value} value={item.value}>
{item.label}
<CommandShortcut>⌘K</CommandShortcut>
</CommandItem>
)}
</CommandList>
</Command>Command— the palette root (data-slot="command"). Owns the query, the query-filtereditems, and keyboard navigation over its items. Always rendered "open" (Base UI'sinlinemode); renders an inlinebg-popoversurface.CommandInput— the search field (data-slot="command-input") with a leading search icon. Control it withinputValue/onInputValueChangeonCommand, or let it manage its own state.CommandList— the scrollable listbox (data-slot="command-list",role="listbox") holding groups and items. Accepts a function child ({(item) => <CommandItem .../>}) for flat, filtered rendering, or static composition (e.g.CommandGroups) for grouped rendering.CommandEmpty— shown when the query-filtereditemsis empty (data-slot="command-empty"). RequiresitemsonCommand. Renders as a sibling ofCommandList.CommandLoading— an announced status row for asynchronous results (data-slot="command-loading", Base UI'sCombobox.Status). Compose<Spinner size="inherit" label="" />+ a message as children. Renders as a sibling ofCommandList.CommandGroup— a labeled section (data-slot="command-group"). Passheadinganditems—itemsmust be the already query-filtered subset (fromuseCommandFilteredItems), not the group's original static array.CommandItem— a selectable row (data-slot="command-item"). WireonSelectto run a command — fires on click, orEnterwhile the item is highlighted. The active row carries Base UI'sdata-highlightedand is styled withbg-accent; passdisabledto mute it (see States for a caveat on keyboard navigation).CommandSeparator— a thin divider between groups (data-slot="command-separator"). Decorative; markedaria-hidden.CommandShortcut— trailing, muted keyboard-hint text on an item (data-slot="command-shortcut"). Purely visual.CommandDialog— aCommandrendered inside the Dialog for the ⌘K overlay (data-slot="command-dialog-content"). Controlled likeDialog(open/onOpenChange); the title/description are visually hidden but announced to screen readers.useCommandFilteredItems— readsCommand's query-filtereditems(call insideCommand). Required for grouped rendering; a thin re-export of Base UI'sCombobox.useFilteredItems.
Examples
States
Pass disabled to a CommandItem to mute it — it stays visible but dimmed (data-[disabled]) and clicking/activating it via Enter is a no-op. Base UI's arrow-key navigation does not skip disabled items the way the prior build did (a verified upstream limitation, not a design choice here) — it still lands on them, it just won't select them. Omit an item from items entirely if it must be unreachable by keyboard. When the query matches no items, CommandEmpty is shown automatically.
<CommandItem value="billing" disabled>
<CreditCard />
Billing
<CommandShortcut>⌘B</CommandShortcut>
</CommandItem>Command dialog (⌘K)
Render the palette inside CommandDialog for the classic command-menu overlay. Open/close is controlled like Dialog; bind the ⌘K shortcut in your app.
The live demo below uses ⌘J, not ⌘K. This documentation site already binds ⌘K to its own search dialog, and two page-level handlers on one chord open both dialogs at once. Your app binds ⌘K, exactly as the snippet shows.
import {
CommandDialog,
CommandInput,
CommandEmpty,
CommandList,
CommandItem,
} from "@/components/ui/command";
const [open, setOpen] = React.useState(false);
React.useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((prev) => !prev);
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
<CommandDialog
open={open}
onOpenChange={setOpen}
commandProps={{
items: [{ value: "dashboard", label: "Dashboard" }],
loop: true,
}}
>
<CommandInput placeholder="Type a command or search…" />
<CommandEmpty>No results found.</CommandEmpty>
<CommandList>
{(item) => (
<CommandItem
key={item.value}
value={item.value}
onSelect={() => setOpen(false)}
>
{item.label}
</CommandItem>
)}
</CommandList>
</CommandDialog>;Async results
Use CommandLoading while fetching remote results. Once items arrive, feed them into items reactively — filtering runs automatically once they're present.
const [loading, setLoading] = React.useState(false);
const [items, setItems] = React.useState<{ value: string; label: string }[]>(
[],
);
React.useEffect(() => {
let cancelled = false;
async function loadItems() {
setLoading(true);
const nextItems = await getCommands();
if (!cancelled) {
setItems(nextItems);
setLoading(false);
}
}
loadItems();
return () => {
cancelled = true;
};
}, []);
<Command items={items}>
<CommandInput placeholder="Search remote commands…" />
<CommandLoading>
{loading ? (
<>
<Spinner size="inherit" label="" />
Fetching commands…
</>
) : null}
</CommandLoading>
<CommandEmpty>No results found.</CommandEmpty>
<CommandList>
{(item) => (
<CommandItem key={item.value} value={item.value}>
{item.label}
</CommandItem>
)}
</CommandList>
</Command>;Advanced filtering
Use a custom filter on Command to match against more than the visible label — fold alias terms into the item data and check them yourself (there's no per-item keywords prop; filtering is data-driven off items, not per-rendered-item metadata). Control inputValue / onInputValueChange when a sibling (like a custom empty state) needs the current query.
const items = [
{ value: "invoices", label: "Invoices", keywords: ["billing", "payments"] },
{ value: "docs", label: "Docs", keywords: ["files", "knowledge"] },
];
const [search, setSearch] = React.useState("");
<Command
items={items}
inputValue={search}
onInputValueChange={setSearch}
filter={(item, query) => {
const haystack = [item.label, ...(item.keywords ?? [])]
.join(" ")
.toLowerCase();
return haystack.includes(query.toLowerCase());
}}
loop
>
<CommandInput placeholder="Search by label or alias…" />
<CommandEmpty>No commands found for "{search}".</CommandEmpty>
<CommandList>
{(item) => (
<CommandItem key={item.value} value={item.value}>
{item.label}
</CommandItem>
)}
</CommandList>
</Command>;API Reference
Command
| Prop | Type | Default | Description |
|---|---|---|---|
items | unknown[] | { heading?: React.ReactNode; items: unknown[] }[] | — | Flat items, or groups of items. Drives filtering and CommandEmpty. |
children | React.ReactNode | — | Command parts rendered inside the root. |
className | string | — | Merged onto the styled command root. |
defaultValue | unknown | — | Initial selected item value. |
value | unknown | — | Controlled selected item value. |
onValueChange | (value: unknown) => void | — | Called when the selected item value changes. |
inputValue | string | — | Controlled search query. |
onInputValueChange | (value: string) => void | — | Called when the search query changes. |
filter | ((item: unknown, query: string) => boolean) | null | — | Custom item filter. Pass null to disable filtering entirely (all items stay mounted). |
loop | boolean | false | Wrap arrow-key navigation from the end back to the start. |
autoHighlight | boolean | "always" | "always" | Highlight the first matching item automatically. |
disabled | boolean | — | Disable the whole palette. |
CommandDialog
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | — | Command parts rendered inside the dialog shell. |
open | boolean | — | Controlled dialog open state. |
defaultOpen | boolean | — | Initial uncontrolled dialog open state. |
onOpenChange | (open: boolean) => void | — | Called when the dialog requests an open-state change. |
title | string | "Command Menu" | Accessible dialog title, visually hidden. |
description | string | "Search for a command to run." | Accessible dialog description, visually hidden. |
className | string | — | Merged onto the inner Command root. |
commandProps | Omit<CommandProps, "className" | "children"> | — | Props forwarded to the inner Command root, such as items, loop, filter, value, and onValueChange. |
CommandInput
| Prop | Type | Default | Description |
|---|---|---|---|
placeholder | string | — | Placeholder text and fallback accessible name. |
aria-label | string | — | Explicit accessible name for the input. |
aria-labelledby | string | — | ID reference for an external accessible label. |
className | string | — | Merged onto the underlying input. |
CommandList
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | ((item, index: number) => React.ReactNode) | — | A function child for flat, filtered rendering, or static composition (e.g. CommandGroups) for grouped rendering. |
className | string | — | Merged onto the scrollable listbox. |
CommandEmpty
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | — | Empty-state content shown when no items match. |
className | string | — | Merged onto the empty-state row. |
CommandLoading
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | — | Loading content shown while async results are pending. |
className | string | — | Merged onto the loading row. |
CommandGroup
| Prop | Type | Default | Description |
|---|---|---|---|
heading | React.ReactNode | — | Visible group heading. |
items | unknown[] | — | This group's query-filtered items (from useCommandFilteredItems) — read verbatim, not re-filtered. |
children | (item: unknown, index: number) => React.ReactNode | — | Render function invoked once per item in items. |
className | string | — | Merged onto the group wrapper. |
CommandItem
| Prop | Type | Default | Description |
|---|---|---|---|
value | unknown | — | The item's value, matched against Command's items/selection. |
disabled | boolean | — | Mute the item; clicking/Enter-activating it is a no-op. |
onSelect | (value: unknown) => void | — | Called when the item is activated (click, or Enter while highlighted). |
children | React.ReactNode | — | Item label, icon, and optional shortcut. |
className | string | — | Merged onto the item row. |
CommandSeparator
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Merged onto the decorative divider. |
CommandShortcut
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | — | Shortcut hint text. |
className | string | — | Merged onto the trailing shortcut text. |
CommandFooter
CommandFooter adds no props of its own — it accepts everything the underlying element or Base UI primitive accepts (className, ref, ARIA attributes, event handlers).
Data attributes and CSS variables on CommandFooter
| Attribute | Values |
|---|---|
data-slot | "command-footer" |
CommandFooter is the hairline-topped action bar at the bottom of a palette. Put keyboard hints on
the left and the primary action on the right; keep it outside CommandList so the listbox owns only
valid options and groups.
useCommandFilteredItems
useCommandFilteredItems() reads Command's query-filtered items (call it inside a Command tree). Required for grouped rendering: CommandGroup's own items prop is read verbatim, not re-filtered — map each group's items from this hook's result, not the original static array, or typing won't narrow the groups.
Accessibility
Commandexposes arole="listbox"ofrole="option"items, with the search input wired viaaria-controls/aria-activedescendant— assistive tech announces the highlighted item as you navigate.CommandDialogrenders the palette inside arole="dialog"+aria-modal="true"popup. Its title/description are visually hidden (sr-only) but wired asaria-labelledby/aria-describedby— always announced.- Focus is trapped in the dialog while open and restored to the opener on close. The input keeps focus so typing filters immediately.
- The search icon is decorative (
aria-hidden); the input gets an accessible name fromaria-label,aria-labelledby, or its placeholder fallback. CommandSeparatoris markedaria-hidden(decorative) andCommandEmpty/CommandLoadingrender outside the listbox — together this keepsCommandList'srole="listbox"owning only validgroup/optionchildren, so the markup passesaxewith no rule suppression.
| Key | Action |
|---|---|
| ↑ / ↓ | Move the highlight between items (does not skip disabled items — see States). |
| Home / End | Jump to the first / last item. |
| Enter | Activate the highlighted item (fires its onSelect). |
| Esc | Close the dialog (when inside CommandDialog). |
| typing | Filter the list against the query. |
| Contract | States tested |
|---|---|
| Behaviour | default, active, disabled, empty, expanded, filtering, loading, open |
| Accessibility | expanded, labeled, status-announcement |
| Visual | default, disabled, loading, empty |
Do / Don't
Context Menu
A menu of actions revealed by right-clicking (or long-pressing) a target — items, submenus, separators, labels, shortcuts, and checkbox/radio selections.
Stepper
A bounded linear process as an ordered list — complete/current/upcoming/error states, aria-current="step", advance gating, and focus that follows the process.