Skip to content
Component installs need the registry setup
VegaStack Design

Command Menu

A searchable command palette — filtered, grouped items with keyboard navigation, optionally inside a ⌘K dialog.

Status
stable
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 command

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/command

Run 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

Command — data-slot="command"
CommandDialog
CommandInput — data-slot="command-input" | "command-input-wrapper"
CommandList — data-slot="command-list"
CommandEmpty — data-slot="command-empty"
CommandLoading — data-slot="command-loading"
CommandFooter — data-slot="command-footer"
CommandActions — data-slot="command-actions"
CommandGroup — data-slot="command-group"
CommandItem — data-slot="command-item"
CommandShortcut — data-slot="command-shortcut"
CommandSeparator — data-slot="command-separator"
CommandFilters — data-slot="command-filters"

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
        └── CommandItem

Basic

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.

Depot review
Fleet renewal
Quarterly planning
Driver onboarding

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.

Depot review
Fleet renewal
Quarterly planning
Driver onboarding

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.

↑↓ to navigate↵ to selectEsc to close

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

PropTypeDefaultDescription
resultsLabel((count: number) => string)—
PropTypeDefaultDescription
children*React.ReactNode—
classNamestring—
descriptionstring—
showCloseButtonboolean—
sizeCommandDialogSize—
titlestring—

CommandLoading adds no props of its own — it accepts everything the underlying element or Base UI primitive accepts (className, ref, ARIA attributes, event handlers).

PropTypeDefaultDescription
hintsreadonly 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" with aria-expanded, aria-controls and aria-activedescendant; the list is a role="listbox" of role="option" rows.
  • ↑/↓ move the active row, Home/End jump to the first and last, and Enter activates. Navigation skips a disabled row rather than landing on it.
  • Filtering is announced. cmdk ships no live region, so Command renders one polite role="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 listbox and the empty message carries role="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: CommandEmpty still goes inside CommandList.
  • CommandActions items stay options inside the listbox, so with footer actions and no results the list stays a listbox; 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 local cursor-default is removed.
  • In the dialog form, give CommandDialog a title and description — 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…", and Command's resultsLabel sets the announced count ("1 result", "3 results" by default), so every built-in string can be localised (VOI-1).
  • CommandLoading is a polite role="status" whose text is spoken (API-18), and CommandFooter sits outside the listbox.
  • A two-line row's ItemDescription is 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 not aria-selected or aria-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.
ContractStates tested
Behaviourdefault, filtered, empty, selected, disabled, open, loading
Accessibilitynative-or-base-ui-semantics, browser-accessibility-test, keyboard-navigation
Visualdefault, selected, empty, disabled, short-viewport, with-footer, loading

Do / Don't

Do
Use a Command palette for an unbounded, searchable set of actions — global navigation, a ⌘K menu, a long list of commands.
Don't
Use a Command palette to pick one value for a form field — that is a Select, or a Combobox when the list needs searching.

Deviations

Upstream's file plus packages/ui/upstream/patches/command.patch. Every hunk:

  • A11Y-2 — CommandInput gains min-h-6. cmdk's input carries no height of its own, so it measures its line box — 20px inside the 32px InputGroup, under the 24px target floor. Its focus affordance needed the matching half in input-group: upstream names this slot command-input, so a group keyed on input-group-control never saw it focus. The group now keys on any input inside it (has-[input:focus,textarea:focus]), so its border darkens subtly while command-input has focus — with no fill and no slot-specific class (FOC-3, and FOC-14's text-entry exception, MK 2026-09-27, in input-group.patch).
  • A11Y-3, A11Y-4 — cmdk ships no live region, so filtering a palette to two results, or to none, is silent. Command now renders one useAnnouncer region 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", never alert). It reads the count through cmdk's own useCommandState, 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 and role="presentation" on the empty slot, so an empty palette is a listbox whose only child is the empty message — a critical aria-required-children failure. CommandList removes 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, and CommandEmpty puts role="status" on the message so the empty state is still announced. Upstream's public composition is untouched.
  • API-18 — CommandLoading over cmdk's loading slot, corrected from a value-less progressbar with hidden text to a polite status whose text is spoken, and a CommandFooter part outside the listbox.
  • API-19 — CommandItem links an ItemDescription inside it as the option's accessible description, through item's ItemDescriptionContext.
  • DOC-1 — prettier reflow plus the registry-stamp.mjs provenance header; no decision of their own.
  • DOC-2 — cn is imported from @vegastack/design; our npm layer owns cn.
  • INT-1 — cursor-default is removed from CommandItem, so a row shows the hand cursor the one global rule gives every [role="option"].
  • LAY-10 — CommandDialog restores DialogContent's centered transform, caps its block size against 100dvh, 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 — CommandDialog takes DialogContent's size scale, and the list grows taller at lg and xl. Its default is 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-in Kbd key hints in CommandFooter, and the animated search icon in CommandLoading.
  • VOI-1 — the default title and description are "Command palette" and "Search for a command…", and resultsLabel overrides 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; CommandEmpty renders its own cmdk-empty node 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. Command vetoes cmdk's highlight-first landing on an action unless a navigation key or the pointer moved there (MK, 2026-10-07).

On this page