Skip to content

CommandPalette

Terminal window
$ npx shadcn@latest add https://lacodda.github.io/dowel/r/command-palette.json
See it liveOpen and listing everything, narrowed by a query, and with nothing matching.→

This is the part worth reading before the props.

dowel supplies the box, the filtering and the keyboard. The dim over the page, the box in the upper third of it, the field, the narrowing as you type, the highlight, the arrows, the type-ahead, Enter, Escape, and the announcement of all of it to a screen reader.

The product supplies what makes it that product’s palette:

  • the items — what is in the box at all, and what each row shows;
  • the grouping — whether commands, recent files and settings are three labelled groups or one flat list;
  • what running one does — every word, and every consequence.

That division is why items is unknown[] rather than a list of strings. A palette lists commands, works, settings and recent files in the same box; a type admitting only strings would push every product into the same stringly-typed workaround, mapping identifiers back to objects on the way out.

It is also why nothing here has a word of its own. The empty state has no default text, the field has no default placeholder, and the popup has no default name — those are the product’s, in the product’s language, and a default here would ship English to every reader who does not read English with no way for the product to reach it.

It is a Combobox, not a Dialog with a field in it

Section titled “It is a Combobox, not a Dialog with a field in it”

The obvious build is a Dialog containing a search input. That is the wrong arrangement, and it fails quietly: a screen reader is told about a dialog that happens to contain a text box, with no stated relationship between what is typed and the list that changes underneath.

Base UI’s own arrangement is the other way round — put the input inside the popup and the popup becomes role="dialog" on its own, while the input is still announced as the role="combobox" that owns the list. Both are true at once, which is the whole reason this component exists rather than being assembled per product.

Everything that follows from that is Base UI’s and is not reimplemented here: the filtering with Intl.Collator (so accents and case behave the way a reader in that language expects, rather than the way toLowerCase does), the highlight, the arrow keys, and the type-ahead.

The one thing the arrangement costs. A Dialog takes its name from a visible title; a palette has no title — it opens straight onto a field — so there is nothing for the popup to point aria-labelledby at, and an unnamed dialog is one a screen reader announces as nothing.

So aria-label on CommandPalettePopup is required by the type, not merely recommended: the component cannot supply it for the reason above, and a palette that forgets it fails to compile rather than shipping a dialog that announces itself as nothing.

CommandPalette is controlled by open / onOpenChange, because what opens a palette is a keystroke bound elsewhere in the application — a command, so the key is in the sheet of shortcuts and the palette lists itself. The popup is anchored to the viewport rather than to a trigger, since it has no trigger to point at.

Pressing Down past the last row does not jump straight back to the first: the highlight comes off the list and back onto the query, and the next Down enters at the top again. That is right for a palette — the field is where the reader edits what they typed, so walking off the end should reach it rather than skip it — but it is one stop more than the list has, which is worth knowing before it looks like a bug.

Nothing in the list ever takes focus. The highlight is aria-activedescendant on the input throughout, which is what lets typing carry on between arrow presses.

import { useCommand } from '@/components/ui/commands'
import {
CommandPalette,
CommandPaletteEmpty,
CommandPaletteInput,
CommandPaletteItem,
CommandPaletteList,
CommandPalettePopup,
CommandPaletteRow,
} from '@/components/ui/command-palette'
const [open, setOpen] = useState(false)
useCommand({ id: 'palette', label: t('commands'), keys: 'Mod+K', run: () => setOpen(true) })
<CommandPalette
items={commands}
open={open}
onOpenChange={setOpen}
onValueChange={(command) => command && run(command)}
>
<CommandPalettePopup aria-label={t('commands')}>
<CommandPaletteInput
aria-label={t('command')}
placeholder={t('typeACommand')}
hint="Escape"
/>
<CommandPaletteEmpty>{t('nothingMatched')}</CommandPaletteEmpty>
<CommandPaletteList>
{(command) => (
<CommandPaletteItem key={command.id} value={command}>
<CommandPaletteRow icon={command.icon} hint={t(command.where)}>
{t(command.label)}
</CommandPaletteRow>
</CommandPaletteItem>
)}
</CommandPaletteList>
</CommandPalettePopup>
</CommandPalette>

onValueChange reports null as well as a value — a palette that is cleared says so — which is why the example checks before running anything.

The rows a palette runs are the commands the application has declared — the same list the keyboard answers and the sheet of shortcuts reads, so a row’s key hint is the key that works, after a rebinding and without any lost to a conflict. The ones with a run are the ones a palette can offer; matchCommands ranks them by label, and groupCommands puts them under their headings:

const runnable = useCommandList().filter((command) => command.run)
const groups = groupCommands(matchCommands(query, runnable))
<CommandPalette items={groups} filter={null} inputValue={query} onInputValueChange={setQuery}
onValueChange={(command) => { command?.run(); setOpen(false) }}>
…
<CommandPaletteItem key={command.id} value={command}>
<CommandPaletteRow icon={command.icon} hint={command.keys[0] && <Kbd keys={command.keys[0]} aria-hidden />}>
{command.label}
</CommandPaletteRow>
</CommandPaletteItem>

filter={null} because the ranking has already narrowed them — filtered a second time, by the Combobox’s own rule, the order matchCommands chose would be lost.

A palette that lists works, versions and notes together is a List over the groups, with a Collection inside each — not a List inside a List, since the list is the listbox and there is one per palette:

<CommandPaletteList>
{(group: Group) => (
<CommandPaletteGroup key={group.kind} items={group.items}>
<CommandPaletteGroupLabel>{t(`kind.${group.kind}`)}</CommandPaletteGroupLabel>
<CommandPaletteCollection>
{(hit: Hit) => (
<CommandPaletteItem key={hit.id} value={hit}>
<CommandPaletteRow hint={t(`kind.${group.kind}`)}>{hit.title}</CommandPaletteRow>
</CommandPaletteItem>
)}
</CommandPaletteCollection>
</CommandPaletteGroup>
)}
</CommandPaletteList>

The arrow keys walk the flattened rows, so reaching the end of one group steps into the next rather than stopping at a caption.

Mapping a group’s rows by hand works too, but then the palette has to be told how to match an item back to a value — and for rows fetched fresh from a server, identity comparison never does, so it needs an isItemEqualToValue that Collection makes unnecessary.

The filtering is client-side over items. A palette whose search happens on a server should pass filter={null}: without it the already-filtered hits are filtered a second time against the same query, by title alone, which throws away every hit that matched on something the client cannot see.

CommandPalette when it is the same box for the whole application, reached by a shortcut from anywhere, and choosing a row does something.

SearchField when the query filters something already on screen, in place, and there is nothing to choose.

Combobox when the typing ends in a value going into a form field.

CommandPaletteEmpty stays mounted so its announcement fires, so it must not be hidden or rendered conditionally.

commandPalettePopupVariants is exported for the same clothes elsewhere, and commandPaletteItemVariants is Combobox’s row - a palette is a list of choices, and two lists of choices in one product should not differ.

Props

Read from the types in ui/command-palette.tsx, the file a product copies - so the description of each prop is also what an editor shows over it there.

CommandPalette

The root. Controlled by open/onOpenChange, because what opens a palette is a shortcut somewhere else in the application.

Base UI's Combobox.Root, unchanged under dowel's name - its props are on its own page.

CommandPaletteItem

A row. The same clothes as a Combobox row, on purpose: a palette is a list of choices, and two lists of choices in one product should not differ.

Every prop of Base UI's Combobox.Item, passed on.

CommandPaletteList

The list. Takes a render function over the filtered items.

Every prop of Base UI's Combobox.List, passed on.

CommandPaletteEmpty

Shown when nothing matches. The words are the product's.

Every prop of Base UI's Combobox.Empty, passed on.

CommandPaletteGroup

A labelled group, for a palette that lists more than one kind of thing.

Base UI's Combobox.Group, unchanged under dowel's name - its props are on its own page.

CommandPaletteGroupLabel

The caption above a group.

Base UI's Combobox.GroupLabel, unchanged under dowel's name - its props are on its own page.

CommandPaletteCollection

The rows of one group, as a render function over that group's items.

Base UI's Combobox.Collection, unchanged under dowel's name - its props are on its own page.

CommandPalettePopup

The palette itself: a dim over the page, and the box in the upper third of it - where the eye already is, rather than dead centre.

PropTypeDefault
sizemd | lg'md'
aria-labelrequiredstringWhat the palette is called, for a screen reader. Required, and required for a reason particular to this component: the popup is a dialog, and a dialog is named by its own visible title - which a palette does not have, because the field is the first thing in it.
containerHTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | nullWhere to portal to. Defaults to the document body.

And every prop of Base UI's Combobox.Popup except aria-label, passed on.

CommandPaletteInput

The field. Sits inside the popup, which is what makes the popup a dialog and the field its combobox.

PropTypeDefault
hintstringShown at the right of the field, as Escape. Decorative.

And every prop of Base UI's Combobox.Input, passed on.

CommandPaletteRow

The row's own layout: an icon, what it is, and where it lives.

PropTypeDefault
iconReactNodeDrawn before the label. Size and colour are the product's.
hintReactNodeDrawn after the label, small and faint - where the item lives, such as a section name or a shortcut.
classNamestring
childrenrequiredReactNode

And every prop of React's React.HTMLAttributes<HTMLDivElement> except children, passed on.

Accessibility

The examples on the stand, through axe 4.14.0 in a browser, in both themes, against WCAG 2.2 A and AA and axe's best practices: 7 rules applied and passed, and none failed. The accessibility report has every item.

History

Each release that changed ui/command-palette.tsx - the file a product copies - newest first.

  1. v0.41.0
    • docs read every props table from the component's types ee3b159
  2. v0.40.0
    • refactor say state on the element, and measure the palette's anchor apart 8d9b4e1
    • fix hold every surface over the page to its room on both axes d0436a7
  3. v0.36.0
    • fix set typed text at 16px under a finger 967db1d
  4. v0.35.0breaking
    • feat one list of commands for keys, palette and sheet dd9a125
  5. v0.33.0breaking
    • feat no-raw-button and no-arbitrary-scale ac7a11a
  6. v0.32.0breaking
    • feat a layer for popups inside overlays, a body that scrolls alone 5244cfd
  7. v0.18.2
    • fix let a component turn off the focus ring, and dress the palette 0739669
  8. v0.18.1
    • fix five defects a live run found, and none of the tests did 863226c

2 earlier releases: every commit to the file.