Navigation and layout

Command Palette

A searchable keyboard-first command surface composed from 4AF primitives.

⠋

Install

npx shadcn@latest add https://4af.selfsimilar.dev/r/command-palette.json

Usage

import { CommandPalette } from "@/components/ui/command-palette";

<CommandPalette groups={groups} />

API

Main exports: CommandPalette, custom panel, list, row, tab and action parts, Kbd, useCommandSearch, useCommandPanelHighlight

A keyboard-first command and search dialog with fuzzy ranking, groups, nested and custom panels, async, server-driven, and paginated search, header text fields, per-panel header actions, badges, checkboxes, keybinds, and destructive actions.

loader={<MySpinner />} supplies the palette's search, panel, and action indicators. Omit it to inherit LoaderProvider, with AsciiLoader as the fallback. CommandItem data and CommandPanelLoading accept their own loader; a custom icons.pending takes precedence for action rows.

shortcut is on by default and binds ⌘K / Ctrl+K to toggle the palette. The demo on this page uses ⌘⇧P / Ctrl+Shift+P instead, so it doesn't clash with the site search.

useCommandSearch discards results from cancelled requests, even when the search callback ignores its abort signal. Clearing the query or disabling search resets the results, and a late response cannot bring them back or appear during the next query's debounce.

Root onSearch(query, signal) returns Promise<CommandGroup[]>; returned order is preserved. Use searchDebounce and loadingMessage to configure waiting behavior. In custom panels, render the initial loader while searching && results === null.

Header navigation buttons use the compact 6px radius.

Composition

The palette enters and exits like Dialog: the backdrop fades, the panel scales in from a small blur, and the exit fades and blurs without reversing the entrance. Reduced motion skips the transition.

The registry item includes:

  • root groups and rows with icons, descriptions, badges, keybinds, and destructive state;
  • nested item lists;
  • arbitrary custom panels opened through helpers.openPanel;
  • custom panel header search fields or traditional text fields;
  • Escape-to-clear for type="search" header fields, while traditional text fields use Escape to go back;
  • per-panel headerActions, suitable for Select and Select Sort controls—use the default size="sm" so they fit the palette;
  • custom list rows with trailing badges or selection checkboxes;
  • grouped action panels with section labels, icons, keybinds, search, and destructive actions;
  • async or server-driven search: pass onSearch on the root with groups as the resting state, or call useCommandSearch in a custom panel and render CommandPanelLoading for its first-load state. Root failures render searchErrorMessage (default: “Search failed. Change your query to retry.”); custom panels receive the failure as the hook's error;
  • paginated custom panels: observe the shared keyboard and pointer highlight with useCommandPanelHighlight, fetch when the last row is reached, and pass that request as additionalRefreshing to useCommandSearch so the panel header keeps showing its inline loader;
  • a tab row directly under a custom panel's header, with CommandPanelTabs;
  • opening the palette straight onto one page with initialItemId.

The demo's Projects, Rename Project, New Project, and Keyboard Shortcuts flows are built only from these public APIs.

Panel tabs

A custom panel can put a row of tabs directly under its header. CommandPanelTabs is a Base UI Tabs root that wraps everything below the header — the row, the body, and the footer — so arrow keys, Home/End, and the tab/panel wiring come from the primitive. CommandPanelTabList draws the row and its one travelling indicator; variant picks how the selected tab is marked:

variantSelected tab
fill (default)A muted fill slides behind it
underlineA hairline slides along the rule under the row
<CommandPanelTabs value={tab} onValueChange={setTab}>
  <CommandPanelTabList aria-label="Sections" variant="underline">
    <CommandPanelTab value="all">All</CommandPanelTab>
    <CommandPanelTab value="recent">Recent</CommandPanelTab>
  </CommandPanelTabList>
  <CommandPanelTabPanel value={tab}>
    <CommandPanelBody>…</CommandPanelBody>
  </CommandPanelTabPanel>
  <CommandPanelFooter />
</CommandPanelTabs>

Both variants sit on the header's bottom rule. Tabs activate as the arrow keys reach them (activateOnFocus is on by default here). When the tabs filter one list, a single CommandPanelTabPanel whose value follows the selection is enough. A header search field filters within the selected tab.

Within a custom panel using these tabs, Tab selects the next enabled tab and Shift+Tab the previous one, wrapping at either end and skipping disabled tabs. Focus stays where it is, so the header search field keeps its query and you can continue typing after switching. Panels without a tab bar keep ordinary Tab focus navigation; open menus keep their own keys.

The indicator animates between the selected tabs.

Use these panel tab parts instead of nesting the standalone Tabs component.

Opening on a page

initialItemId opens the palette straight into one item's custom panel or sub-list instead of the root list, for a shortcut that opens one page, such as ? for a help panel. It is read each time the palette opens. From that page, Escape and Back close the palette instead of showing the root list; pages opened from the root still step back as usual.

<CommandPalette groups={groups} open={open} onOpenChange={setOpen} initialItemId={entry} />

Selection and hover

Every list — the root list and each custom panel's — opens with its first row selected, before anything is typed, and moves the selection to the new first row as a query narrows the list. Enter runs the selected row without an arrow press first.

Selection and hover are separate states. The selected row carries data-highlighted and moves with the arrow keys and with presses. Hovering gives a row a weaker fill only, so moving the pointer across the list never changes what Enter runs. A press selects a row and runs it, so in a panel that stays open, such as a checkbox list, the selection follows the row you pressed. Moving the pointer out of the list leaves the selection where it was.

While a row's actions menu is open, the row carries data-frozen, styled like the selection, because Base UI drops its own highlight while focus is inside the menu.

When the selection reaches the first row, the list scrolls to its top, so the section label above that row is visible. This applies to the root list and to CommandPanelList.

The root list primes its first row from the items collection through autoHighlight. Custom panels have no such collection, so CommandPanelList activates its first row after mount. Preserve that initialization when changing panel composition; root highlighting does not initialize custom panels.

The no-results state is the height of one section label plus one row with a description.

The root list's bar has the escape hint and the navigation hint on the left, and the Enter hint pinned right. A custom panel's footer keeps those two ends — escape on the left, Enter on the right — and fills the right corner with two optional slots, in this order:

SlotPropKey
The primary actionenterReturn, fixed
The contextual actions menuactionsCmd/Ctrl + J, fixed
<CommandPanelFooter
  enter={{ label: "Save", type: "submit" }}
  actions={{ groups: actionGroups }}
/>

Neither key is configurable, and nothing else can go in that corner, so every panel uses the same keys. enter takes label, onClick, disabled, and type. "submit" submits the containing form; an onClick handler, if provided, also runs. actions takes the menu's own props: items or groups, plus label, searchPlaceholder, and emptyMessage.

An actions row normally runs onSelect and closes the menu. The fields below make it show its result in place instead, keeping the menu open. They match Dropdown Menu's row types:

ShapeFieldsDropdown equivalent
Runs, confirms, revertsactionDropdownMenuActionItem
Runs, then lands checkedaction + checked/defaultCheckedDropdownMenuCheckboxItem with action
Flips at once, morphingchecked/defaultCheckedDropdownMenuCheckboxItem + Morph
Asks, then commits and closesconfirm + onSelectDeleteButton
<CommandPanelFooter
  actions={{
    items: [
      { id: "sync", label: "Sync now", action: sync, labels: { pending: "Syncing…", success: "Synced" } },
      { id: "watch", label: "Watch", defaultChecked: false, action: watch, labels: { checked: "Watching" } },
      { id: "star", label: "Star", defaultChecked: false, labels: { checked: "Starred" } },
    ],
  }}
/>

labels and icons carry the other states—checked, pending, success, and error—each falling back to the row's own label or icon. Pending and error default to the shared loader and warning triangle. revertAfter and pendingDelay behave as on Action Button. The two toggle shapes show no success tick; the changed label is the confirmation. An item with none of these fields closes the menu and calls onSelect.

confirm requires two presses: the first arms the row and shows confirm.label, “Confirm?” by default; the second runs onSelect and closes the menu. It disarms on Escape, when the menu closes, and after confirm.resetAfter—three seconds by default, while 0 means never. A press within 350ms of arming is ignored, so one double-click cannot confirm what it just armed. Delete Button follows the same rules.