Command Menu

A searchable command palette built on Base UI Autocomplete, with grouped, virtualized rows and controlled search state.

Installation

import { CommandMenu } from "@o/pipeline/experimental/components/command-menu";

Usage

CommandMenu is a compound set of parts backed by Base UI Autocomplete. You own the search input value, controlled via value / onValueChange or uncontrolled via defaultValue. Pass groups in as data (virtualization needs them for sizing), and render each row through the Items render function.

  • The item type is yours: items extend CommandMenuItem (id / searchValue / keywords) with whatever a row renders (label, symbol, onClick, href, …). Annotate the Items render param with that type, since like Base UI’s Autocomplete, it isn’t inferred from Root’s items.
  • Filtering: applied to the static items internally. Rows match on searchValue, the group label, and any cmdk-style keywords (e.g. a company, domain, or email): related info that isn’t part of the visible label.
  • Selection: Base UI’s onClick on Item; fires on pointer click and on Enter when the row is highlighted.
  • Current state: Item’s selected prop renders a trailing check before the row’s keyboard shortcut, and marks the row aria-current. Use it for a setting whose current value the list is choosing between (e.g. the active theme).
  • Fallback groups: a group with fallback: true shows only when no other group matches the query, and the query never filters it. Use it for what a search with no results offers instead, such as creating a record named after the query.
  • Warm on intent: Root’s onItemHighlighted (Base UI) fires on keyboard arrow and pointer hover, useful for prefetching or preloading a row’s destination before selection.
<CommandMenu.Root value={query} onValueChange={setQuery} items={groups}>
  <CommandMenu.Input />
  <CommandMenu.List>
    {loading && <CommandMenu.Loading />}
    {!loading && <CommandMenu.Empty />}
    <CommandMenu.Items>
      {(item: CommandItem) => (
        <CommandMenu.Item
          key={item.id}
          value={item}
          symbol={item.symbol}
          kbd={item.kbd}
          onClick={item.onClick}
        >
          {item.label}
        </CommandMenu.Item>
      )}
    </CommandMenu.Items>
  </CommandMenu.List>
  <CommandMenu.Footer />
</CommandMenu.Root>

Presentation

Warning

CommandMenu is designed to be presented by CommandMenu.Dialog, and may not work as expected in other contexts.

CommandMenu.Dialog is the shell: a top-aligned Dialog above the desktop breakpoint and a Sheet below it, both with a close control and the keyboard-safe focus the sheet needs. Open state, mode and search value stay with you, so crossing the breakpoint while the menu is open keeps what the operator typed.

<CommandMenu.Dialog
  aria-label="Command palette"
  open={open}
  onOpenChange={setOpen}
  onOpenChangeComplete={reset}
>
  <CommandMenu.Root …>{/* … */}</CommandMenu.Root>
</CommandMenu.Dialog>

Narrow the frame below to see the sheet.

Async results and status rows

Remote and async results are your responsibility: compute items from the controlled value, and drive the status rows from your own fetch state.

CommandMenu.Empty and CommandMenu.Loading are status rows (wrapping Base UI’s Empty / Status) composed inside List. The three states (items, no results, and searching) are mutually exclusive: both rows hide themselves once there are matches, so they only ever show in place of an empty list. Render Loading while a request is in flight and Empty otherwise:

<CommandMenu.List>
  {isFetching && <CommandMenu.Loading />}
  {!isFetching && <CommandMenu.Empty />}
  <CommandMenu.Items>{(item) => /* … */}</CommandMenu.Items>
</CommandMenu.List>

Empty and Loading share the same shape (an icon prop and caption children, each with a default), so you customize either one the same way:

<CommandMenu.Empty icon={<IconInbox />}>Nothing here yet</CommandMenu.Empty>
<CommandMenu.Loading icon={<Spinner />}>Searching…</CommandMenu.Loading>

Loading holds itself back for a moment before it fades in, so a search that settles quickly never flashes it.

In this example, the “Deals” results resolve after a delay, so typing shows the Loading row before the results appear:

Input hint

CommandMenu.Hint labels a key the input handles itself, set like the footer’s hints. Pass it to Input’s end slot and handle the key in onKeyDown:

<CommandMenu.Input
  end={<CommandMenu.Hint kbd="Tab">Ask Operate</CommandMenu.Hint>}
  onKeyDown={(event) => {
    if (event.key === "Tab") {
      event.preventDefault();
      ask(event.currentTarget.value);
    }
  }}
/>