CommandPalette
A command palette is a search box over everything a user can do. This
one is a modal Dialog holding a Headless UI Combobox:
the dialog gives the focus trap, scroll lock and Escape; the combobox
gives typeahead, arrow keys and role="listbox" semantics. It never
holds a value: choosing an item runs it and closes the palette.
Below sm (40rem) it is a full-width sheet pinned to the top of the
viewport, whatever its size.
import CommandPalette from '@zuilib/primitives/command-palette'
Basic
open / onOpenChange follow the Dialog contract. Items carry an onSelect
or an href; group sorts them under the headings you list in groups.
Selected items lead the list under Recent the next time it opens.
Recent items
The ids of chosen items are kept most-recent-first, capped at maxRecent
(5). Pass storageKey to persist them in localStorage, or control the
list with recentIds / onRecentIdsChange (a per-user store). With an empty
query the recent items are listed first and left out of their own
groups; typing hides the recent group.
<CommandPalette storageKey="app.commands" maxRecent={3} {...rest} />
Async results
filter={false} turns the built-in matching off and renders items as
given, so onQueryChange can drive a server search; loading swaps the
list for a spinner row while it runs.
const [query, setQuery] = useState('')
const {data, isFetching} = useSearch(query)
<CommandPalette
items={data ?? []}
filter={false}
onQueryChange={setQuery}
loading={isFetching}
{...rest}
/>
Props
CommandPaletteProps extends the <div> attributes (they land on the
dialog root) with:
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Controlled visibility; use defaultOpen for uncontrolled |
defaultOpen | boolean | false | Uncontrolled initial visibility |
onOpenChange | (open: boolean) => void | — | False on Escape, backdrop click, a selection and the shortcut while open; true from the shortcut while closed. Flip open in response |
itemsrequired | CommandPaletteItem[] | — | {id, label, description?, icon?, shortcut?, keywords?, group?, href?, onSelect?, disabled?} |
groups | {id: string; label: string}[] | — | Order and headings of the groups; ungrouped items come first, unknown groups are appended headed by their id |
onSelect | (item) => void | — | Every selection, before the item's own onSelect |
onNavigate | (href, item) => void | window.location.assign | For items with href; pass your router's navigate |
shortcut | string | — | Global keyboard shortcut that toggles the palette: 'mod+k' (mod is Meta or Ctrl), 'ctrl+shift+p', '/'. A shortcut without Ctrl/Meta/Alt is ignored while a text field has focus. Off by default |
placeholder | string | 'Type a command or search…' | Labels.commandPalettePlaceholder |
emptyMessage | ReactNode | 'No results found.' | Labels.noResults |
loading | boolean | false | Spinner row instead of the list |
loadingMessage | ReactNode | 'Loading…' | Labels.loadingMessage |
onQueryChange | (query: string) => void | — | Every keystroke, and '' when the palette closes |
filter | false | (item, query) => boolean | label, description and keywords, case-insensitive | false renders items as-is |
recentIds | string[] | — | Controlled recent ids, most recent first |
onRecentIdsChange | (ids: string[]) => void | — | |
storageKey | string | — | Persist the recent ids in localStorage under this key. Ignored while recentIds is controlled; changing the key re-reads storage |
maxRecent | number | 5 | |
recentLabel | string | 'Recent' | Labels.recent |
label | string | 'Command palette' | Accessible name of the dialog and the input; Labels.commandPalette |
size | 'sm' | 'md' | 'lg' | 'xl' | 'full' | 'lg' | Dialog size |
role | 'dialog' | 'alertdialog' | 'dialog' | |
track | string | — | Names the select telemetry event (value is the item id) |
className | string | — | Merged last onto the panel |
children | ReactNode | — | Rendered in a footer strip under the list |
Slots
| Slot | Element | Notes |
|---|---|---|
[data-slot="command-palette"] | div | The dialog root (see Dialog for its attributes); print:hidden |
[data-slot="command-palette-panel"] | div | The dialog panel with --dialog-padding zeroed |
[data-slot="command-palette-header"] | div | Search icon + input row |
[data-slot="command-palette-input"] | input | role="combobox", focused on open |
[data-slot="command-palette-list"] | div | role="listbox", scrolls past 80 spacing units |
[data-slot="command-palette-group"] | div | role="group" labelled by its heading (none for ungrouped items) |
[data-slot="command-palette-heading"] | div | — |
[data-slot="command-palette-item"] | div | role="option" with data-item-id; Headless UI data-focus / data-disabled |
[data-slot="command-palette-item-icon"] | span | aria-hidden |
[data-slot="command-palette-item-content"] | span | Label and description column |
[data-slot="command-palette-item-description"] | span | — |
[data-slot="command-palette-item-shortcut"] | kbd | aria-hidden |
[data-slot="command-palette-loading"] | div | Spinner row while loading |
[data-slot="command-palette-empty"] | div | The emptyMessage row |
[data-slot="command-palette-status"] | span | Visually hidden live region for loading / empty text |
[data-slot="command-palette-footer"] | div | Present when children are given |
Tokens
| Token | Used for |
|---|---|
--dialog-width-lg (or the chosen size) | Panel width |
--control-height-lg, --control-padding-x-md | Input height, row padding |
--popover, --popover-foreground, --border | Panel surface, header and footer rules |
--accent, --accent-foreground | The focused row |
--muted-foreground | Headings, descriptions, shortcuts, the search icon |
Accessibility
- The dialog is
role="dialog"witharia-modal="true"and named bylabel(Command palette); focus lands in the input, is trapped with Tab / Shift+Tab, and returns to the opener on close. - The input is
role="combobox"witharia-expanded,aria-controlsandaria-activedescendant; the list isrole="listbox", each grouprole="group"labelled by its heading, each rowrole="option"witharia-selectedandaria-disabled. - ArrowUp / ArrowDown move over the enabled options (the first is active on open), Enter runs the active one, Escape closes. The shortcut is ignored while a
keydownwas already handled (defaultPrevented), and a shortcut with no Ctrl/Meta/Alt (such as'/') is ignored while an input, textarea or contenteditable element has focus. - Loading and empty rows are disabled options and are also announced from the
command-palette-statuslive region. - Shortcut hints are decorative (
aria-hidden); bind the keys yourself.