Command Palette

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

Installation

import { CommandPalette } from "@o/pipeline/experimental/components/command-palette";

Usage

CommandPalette 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 CommandPaletteItem (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.
<CommandPalette.Root value={query} onValueChange={setQuery} items={groups}>
  <CommandPalette.Input />
  <CommandPalette.List>
    {loading && <CommandPalette.Loading />}
    {!loading && <CommandPalette.Empty />}
    <CommandPalette.Items>
      {(item: CommandItem) => (
        <CommandPalette.Item
          key={item.id}
          value={item}
          symbol={item.symbol}
          kbd={item.kbd}
          onClick={item.onClick}
        >
          {item.label}
        </CommandPalette.Item>
      )}
    </CommandPalette.Items>
  </CommandPalette.List>
  <CommandPalette.Footer />
</CommandPalette.Root>

Presentation

Warning

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

CommandPalette.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.

<CommandPalette.Dialog
  aria-label="Command palette"
  open={open}
  onOpenChange={setOpen}
  onOpenChangeComplete={reset}
>
  <CommandPalette.Root …>{/* … */}</CommandPalette.Root>
</CommandPalette.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.

CommandPalette.Empty and CommandPalette.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:

<CommandPalette.List>
  {isFetching && <CommandPalette.Loading />}
  {!isFetching && <CommandPalette.Empty />}
  <CommandPalette.Items>{(item) => /* … */}</CommandPalette.Items>
</CommandPalette.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:

<CommandPalette.Empty icon={<IconInbox />}>Nothing here yet</CommandPalette.Empty>
<CommandPalette.Loading icon={<Spinner />}>Searching…</CommandPalette.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

CommandPalette.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:

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