Skip to content
Component installs need the registry setup— the Base UI shadcn project, the @vegastack namespace and the Cloudflare Access service token.
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

Calendar
Search Emoji
Profile⌘P
Billing⌘B
Settings⌘S

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/command

The 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. Command handles search filtering, grouping, and keyboard navigation; each CommandItem exposes an onSelect callback. Wiring those callbacks to routes or actions — and binding the ⌘K shortcut that opens CommandDialog — is the consuming app's responsibility.

Data-driven. Command is built on Base UI's Combobox, rendered always-open in its inline mode (the list is part of the normal layout flow, never a floating popup). Base UI only filters — and drives CommandEmpty — off a query-filtered items array on the root; static CommandItem children never narrow. Pass items to Command (a flat array, or an array of { heading, items } groups) and render with a function child on CommandList (flat) or useCommandFilteredItems + CommandGroup (grouped) — see Anatomy.

Anatomy

Command is a compound component built on Base UI's Combobox. Compose the parts inside the root:

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

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-filtered items, and keyboard navigation over its items. Always rendered "open" (Base UI's inline mode); renders an inline bg-popover surface.
  • CommandInput — the search field (data-slot="command-input") with a leading search icon. Control it with inputValue / onInputValueChange on Command, 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-filtered items is empty (data-slot="command-empty"). Requires items on Command. Renders as a sibling of CommandList.
  • CommandLoading — an announced status row for asynchronous results (data-slot="command-loading", Base UI's Combobox.Status). Compose <Spinner size="inherit" label="" /> + a message as children. Renders as a sibling of CommandList.
  • CommandGroup — a labeled section (data-slot="command-group"). Pass heading and itemsitems must be the already query-filtered subset (from useCommandFilteredItems), not the group's original static array.
  • CommandItem — a selectable row (data-slot="command-item"). Wire onSelect to run a command — fires on click, or Enter while the item is highlighted. The active row carries Base UI's data-highlighted and is styled with bg-accent; pass disabled to mute it (see States for a caveat on keyboard navigation).
  • CommandSeparator — a thin divider between groups (data-slot="command-separator"). Decorative; marked aria-hidden.
  • CommandShortcut — trailing, muted keyboard-hint text on an item (data-slot="command-shortcut"). Purely visual.
  • CommandDialog — a Command rendered inside the Dialog for the ⌘K overlay (data-slot="command-dialog-content"). Controlled like Dialog (open / onOpenChange); the title/description are visually hidden but announced to screen readers.
  • useCommandFilteredItems — reads Command's query-filtered items (call inside Command). Required for grouped rendering; a thin re-export of Base UI's Combobox.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>
Item states
Profile⌘P
Billing⌘B
Settings⌘S
Empty state
No results found.

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>;
Fetching commands…

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>;
Dashboard
Invoices⌘I
Docs

API Reference

Command

PropTypeDefaultDescription
itemsunknown[] | { heading?: React.ReactNode; items: unknown[] }[]Flat items, or groups of items. Drives filtering and CommandEmpty.
childrenReact.ReactNodeCommand parts rendered inside the root.
classNamestringMerged onto the styled command root.
defaultValueunknownInitial selected item value.
valueunknownControlled selected item value.
onValueChange(value: unknown) => voidCalled when the selected item value changes.
inputValuestringControlled search query.
onInputValueChange(value: string) => voidCalled when the search query changes.
filter((item: unknown, query: string) => boolean) | nullCustom item filter. Pass null to disable filtering entirely (all items stay mounted).
loopbooleanfalseWrap arrow-key navigation from the end back to the start.
autoHighlightboolean | "always""always"Highlight the first matching item automatically.
disabledbooleanDisable the whole palette.

CommandDialog

PropTypeDefaultDescription
childrenReact.ReactNodeCommand parts rendered inside the dialog shell.
openbooleanControlled dialog open state.
defaultOpenbooleanInitial uncontrolled dialog open state.
onOpenChange(open: boolean) => voidCalled when the dialog requests an open-state change.
titlestring"Command Menu"Accessible dialog title, visually hidden.
descriptionstring"Search for a command to run."Accessible dialog description, visually hidden.
classNamestringMerged onto the inner Command root.
commandPropsOmit<CommandProps, "className" | "children">Props forwarded to the inner Command root, such as items, loop, filter, value, and onValueChange.

CommandInput

PropTypeDefaultDescription
placeholderstringPlaceholder text and fallback accessible name.
aria-labelstringExplicit accessible name for the input.
aria-labelledbystringID reference for an external accessible label.
classNamestringMerged onto the underlying input.

CommandList

PropTypeDefaultDescription
childrenReact.ReactNode | ((item, index: number) => React.ReactNode)A function child for flat, filtered rendering, or static composition (e.g. CommandGroups) for grouped rendering.
classNamestringMerged onto the scrollable listbox.

CommandEmpty

PropTypeDefaultDescription
childrenReact.ReactNodeEmpty-state content shown when no items match.
classNamestringMerged onto the empty-state row.

CommandLoading

PropTypeDefaultDescription
childrenReact.ReactNodeLoading content shown while async results are pending.
classNamestringMerged onto the loading row.

CommandGroup

PropTypeDefaultDescription
headingReact.ReactNodeVisible group heading.
itemsunknown[]This group's query-filtered items (from useCommandFilteredItems) — read verbatim, not re-filtered.
children(item: unknown, index: number) => React.ReactNodeRender function invoked once per item in items.
classNamestringMerged onto the group wrapper.

CommandItem

PropTypeDefaultDescription
valueunknownThe item's value, matched against Command's items/selection.
disabledbooleanMute the item; clicking/Enter-activating it is a no-op.
onSelect(value: unknown) => voidCalled when the item is activated (click, or Enter while highlighted).
childrenReact.ReactNodeItem label, icon, and optional shortcut.
classNamestringMerged onto the item row.

CommandSeparator

PropTypeDefaultDescription
classNamestringMerged onto the decorative divider.

CommandShortcut

PropTypeDefaultDescription
childrenReact.ReactNodeShortcut hint text.
classNamestringMerged 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

AttributeValues
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

  • Command exposes a role="listbox" of role="option" items, with the search input wired via aria-controls / aria-activedescendant — assistive tech announces the highlighted item as you navigate.
  • CommandDialog renders the palette inside a role="dialog" + aria-modal="true" popup. Its title/description are visually hidden (sr-only) but wired as aria-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 from aria-label, aria-labelledby, or its placeholder fallback.
  • CommandSeparator is marked aria-hidden (decorative) and CommandEmpty/CommandLoading render outside the listbox — together this keeps CommandList's role="listbox" owning only valid group / option children, so the markup passes axe with no rule suppression.
KeyAction
/ Move the highlight between items (does not skip disabled items — see States).
Home / EndJump to the first / last item.
EnterActivate the highlighted item (fires its onSelect).
EscClose the dialog (when inside CommandDialog).
typingFilter the list against the query.
ContractStates tested
Behaviourdefault, active, disabled, empty, expanded, filtering, loading, open
Accessibilityexpanded, labeled, status-announcement
Visualdefault, disabled, loading, empty

Do / Don't

Do
Wire each CommandItem's onSelect to a route or action, group related commands with a heading, and keep labels short and scannable.
Don't
Bind the ⌘K shortcut inside the component, or rely on disabled items being skipped by keyboard navigation — the app owns the toggle, and unreachable items must be left out of items entirely.

On this page