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.jsonUsage
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 defaultsize="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
onSearchon the root withgroupsas the resting state, or calluseCommandSearchin a custom panel and renderCommandPanelLoadingfor its first-load state. Root failures rendersearchErrorMessage(default: “Search failed. Change your query to retry.”); custom panels receive the failure as the hook'serror; - paginated custom panels: observe the shared keyboard and pointer highlight with
useCommandPanelHighlight, fetch when the last row is reached, and pass that request asadditionalRefreshingtouseCommandSearchso 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:
<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.
Panel footer
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:
<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:
<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.