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
Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Command Menu before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify commandInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/commandRun the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.
The same command installs the registry items it composes: @vegastack/dialog, @vegastack/input-group, @vegastack/icon-search, @vegastack/item, @vegastack/kbd, @vegastack/use-announcer, @vegastack/use-platform.
It also adds the sanctioned engine to your package.json: cmdk (command-palette filter and selection engine).
Usage
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command";
<Command className="max-w-sm rounded-lg border">
<CommandInput placeholder="Type a command or search..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Suggestions">
<CommandItem>Calendar</CommandItem>
<CommandItem>Search Emoji</CommandItem>
</CommandGroup>
</CommandList>
</Command>;Anatomy
Examples
About
Command is cmdk — a headless palette engine that scores every item
against the query and unmounts the ones that do not match, rather than hiding them. Items register
themselves from the DOM, so a CommandItem written as a static child filters without any items
array; CommandEmpty renders when the engine's filtered count reaches zero, and CommandSeparator
hides itself while a search is active.
Composition
Command
├── CommandInput
└── CommandList
├── CommandEmpty
├── CommandGroup
│ ├── CommandItem
│ └── CommandItem
├── CommandSeparator
└── CommandGroup
├── CommandItem
└── CommandItemBasic
CommandDialog puts the same palette inside a Dialog. It owns the dialog's title and
description — both rendered sr-only — so a ⌘K palette is named without visible chrome.
Command palette
Search for a command…
Shortcuts
CommandShortcut is the trailing hint on a row. A row carrying one suppresses the check indicator,
so the shortcut and the check never compete for the same slot.
Command palette
Search for a command…
Groups
CommandGroup takes a heading and CommandSeparator divides two of them. A group whose items all
score zero is hidden by the engine, and the separator beside it disappears with the search.
Command palette
Search for a command…
Scrollable
CommandList caps at max-h-72 and scrolls, with scroll-py-1 so the arrow-key cursor never lands
flush against the edge. Inside CommandDialog, the palette is vertically centered and capped to the
dynamic viewport with a standard spacing gap. The command region shrinks before an optional footer,
so the search row and footer stay visible while long results keep scrolling inside CommandList.
Command palette
Search for a command…
RTL
Pass dir to Command and CommandInput. The row's gap, the group heading's padding and the
shortcut's ms-auto are logical properties, so the whole palette mirrors.
Wide palette, filters and key hints
CommandDialog defaults to a wide palette (820px). It also takes DialogContent's sm, lg
and xl sizes; at lg and xl the list grows to min(28rem, 60dvh).
CommandFilters is the row under the search field. Put type chips in it — a ToggleGroup with
wrap, so the chips wrap tidily — and any other control, such as a Select. CommandFooter with
no children shows the built-in key hints, ↵ Open · ⌘↵ New tab · Esc Close (Ctrl ↵ off Apple
platforms), as Kbds; pass hints to change them. The footer sits flush with the bottom edge
and the list ends right above it.
Command palette
Search for a command…
Server results
When results come from a server, turn cmdk's filter off with shouldFilter={false}, feed the
query to your fetch through CommandInput's value/onValueChange, and render CommandLoading
while a request is in flight. It shows the animated search icon beside its text and is a polite
role="status" whose text is spoken (default "Searching…"); pass progress (0–100) and it stays a labelled progressbar instead. Render it above CommandList, or only while the list is empty — a status inside a
listbox that holds options is not a valid listbox child.
Two-line results
Compose ItemContent, ItemTitle and ItemDescription from item inside a CommandItem. The
row links the description as the option's accessible description (aria-describedby), so a screen
reader reads it after the option rather than as a second name.
Footer hints
CommandFooter is a row for key hints, a result count or a link. It sits outside CommandList,
so it is never announced as an option. Children replace the built-in hints; hints (an array of
{ keys, label }) changes them while keeping the Kbd layout.
Footer actions
A searchable picker's static actions — Assign to me, Unassign, Clear, No space — are not
results, so they don't sit among them or above the search. Put them in CommandActions, the last
child of CommandList: a footer behind a hairline that stays at the bottom while the results
scroll. Its CommandItems are never filtered and never counted as results, so a search that matches
nothing still shows "No members found" with the actions under it, and the announced count leaves
them out. They are ordinary options: the arrow keys reach them after the last result — and only
the arrow keys or the pointer highlight one, so pressing Enter after a search that matched nothing
runs nothing. Give each a
leading icon, and disabled when it would change nothing (Assign to me on your own task). Keep
results in a CommandGroup, whose padding keeps the first row's highlight off the search field.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
resultsLabel | ((count: number) => string) | — |
| Prop | Type | Default | Description |
|---|---|---|---|
children* | React.ReactNode | — | |
className | string | — | |
description | string | — | |
showCloseButton | boolean | — | |
size | CommandDialogSize | — | |
title | string | — |
CommandLoading adds no props of its own — it accepts everything the underlying element or Base UI primitive accepts (className, ref, ARIA attributes, event handlers).
| Prop | Type | Default | Description |
|---|---|---|---|
hints | readonly CommandHint[] | — |
CommandActions accepts everything cmdk's Command.Group accepts; the CommandItems inside it are
force-mounted, so cmdk leaves them out of the filter, the ranking and the result count. CommandFilters accepts everything a div accepts. CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandSeparator and
CommandShortcut add no props of their own — each accepts everything the cmdk part (or, for
CommandShortcut, a span) accepts.
Accessibility
- The input is a
role="combobox"witharia-expanded,aria-controlsandaria-activedescendant; the list is arole="listbox"ofrole="option"rows. - ↑/↓ move the active row, Home/End
jump to the first and last, and Enter activates. Navigation skips a
disabledrow rather than landing on it. - Filtering is announced. cmdk ships no live region, so
Commandrenders one politerole="status"region for its whole life and speaks the result count whenever the query changes (A11Y-3, A11Y-4). - With zero results the list stops being a
listboxand the empty message carriesrole="status"(A11Y-7, A11Y-3), so an empty palette is never a listbox holding a non-option child. Nothing about how you compose it changes:CommandEmptystill goes insideCommandList. CommandActionsitems stay options inside the listbox, so with footer actions and no results the list stays alistbox; the empty message is then plain text, and the result announcer speaks the empty count (OVL-20).- Rows carry the hand cursor from the one global rule in
base.css(INT-1); upstream's localcursor-defaultis removed. - In the dialog form, give
CommandDialogatitleanddescription— they are the dialog's accessible name and description even though they are visually hidden. The defaults are "Command palette" and "Search for a command…", andCommand'sresultsLabelsets the announced count ("1 result", "3 results" by default), so every built-in string can be localised (VOI-1). CommandLoadingis a politerole="status"whose text is spoken (API-18), andCommandFootersits outside the listbox.- A two-line row's
ItemDescriptionis the option's accessible description (API-19). - The check mark is visual only. It shows on a row carrying
data-checked="true", which cmdk never sets and which is notaria-selectedoraria-checked— so Command is not a multi-select, and a row you mark checked is not announced as checked. Picking values is a Select or a Combobox.
| Contract | States tested |
|---|---|
| Behaviour | default, filtered, empty, selected, disabled, open, loading |
| Accessibility | native-or-base-ui-semantics, browser-accessibility-test, keyboard-navigation |
| Visual | default, selected, empty, disabled, short-viewport, with-footer, loading |
Do / Don't
Deviations
Upstream's file plus packages/ui/upstream/patches/command.patch. Every hunk:
- A11Y-2 —
CommandInputgainsmin-h-6. cmdk's input carries no height of its own, so it measures its line box — 20px inside the 32pxInputGroup, under the 24px target floor. Its focus affordance needed the matching half ininput-group: upstream names this slotcommand-input, so a group keyed oninput-group-controlnever saw it focus. The group now keys on any input inside it (has-[input:focus,textarea:focus]), so its border darkens subtly whilecommand-inputhas focus — with no fill and no slot-specific class (FOC-3, and FOC-14's text-entry exception, MK 2026-09-27, ininput-group.patch). - A11Y-3, A11Y-4 — cmdk ships no live region, so filtering a palette to two results, or to none,
is silent.
Commandnow renders oneuseAnnouncerregion for its whole life (A11Y-4: mounted empty, keyed by a sequence counter) and announces the result count politely whenever the search or the count changes (A11Y-3:role="status", neveralert). It reads the count through cmdk's ownuseCommandState, so the announcement is the engine's filtered total, not a second count. - A11Y-7, A11Y-3 — a list with no options is not a listbox. cmdk hard-codes
role="listbox"on the list androle="presentation"on the empty slot, so an empty palette is a listbox whose only child is the empty message — a criticalaria-required-childrenfailure.CommandListremoves the role while the filtered count is 0 and restores it above 0, which is A11Y-7's context-licensed role applied to the other end of the same relationship, andCommandEmptyputsrole="status"on the message so the empty state is still announced. Upstream's public composition is untouched. - API-18 —
CommandLoadingover cmdk's loading slot, corrected from a value-lessprogressbarwith hidden text to a politestatuswhose text is spoken, and aCommandFooterpart outside the listbox. - API-19 —
CommandItemlinks anItemDescriptioninside it as the option's accessible description, throughitem'sItemDescriptionContext. - DOC-1 — prettier reflow plus the
registry-stamp.mjsprovenance header; no decision of their own. - DOC-2 —
cnis imported from@vegastack/design; our npm layer ownscn. - INT-1 —
cursor-defaultis removed fromCommandItem, so a row shows the hand cursor the one global rule gives every[role="option"]. - LAY-10 —
CommandDialogrestoresDialogContent's centered transform, caps its block size against100dvh, and gives the command region a shrinkable grid row. Short viewports keep the search row, scrolling results and an optional footer inside the visible browser area. - OVL-16 —
CommandDialogtakesDialogContent'ssizescale, and the list grows taller atlgandxl. Itsdefaultis the wide 820px palette, and the dialog is a gapless flex column, so no empty row or padding sits below the footer. - API-18 (filters, hints) —
CommandFilters(a wrapping row under the input), built-inKbdkey hints inCommandFooter, and the animated search icon inCommandLoading. - VOI-1 — the default title and description are "Command palette" and "Search for a
command…", and
resultsLabeloverrides the announced result count. - OVL-20 —
CommandActions, a sticky footer group for a searchable list's static actions. Its items are force-mounted, which cmdk never registers, so they are not filtered, ranked or counted;CommandEmptyrenders its owncmdk-emptynode on the result count (plain text while actions keep the list a listbox), the list adds the footer's measured height to its bottom scroll padding, and the footer re-appends itself after ranking moves an ungrouped result below it.Commandvetoes cmdk's highlight-first landing on an action unless a navigation key or the pointer moved there (MK, 2026-10-07).