CommandPalette
$ npx shadcn@latest add https://lacodda.github.io/dowel/r/command-palette.jsonWhat is yours and what is ours
Section titled “What is yours and what is ours”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.
Give the popup a name
Section titled “Give the popup a name”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.
It is opened from somewhere else
Section titled “It is opened from somewhere else”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.
The arrows walk through the field
Section titled “The arrows walk through the field”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 commands come from the list
Section titled “The commands come from the list”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.
More than one kind of thing
Section titled “More than one kind of thing”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.
Searching somewhere else
Section titled “Searching somewhere else”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.
Palette or SearchField
Section titled “Palette or SearchField”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.
Parts and variants
Section titled “Parts and variants”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.
| Prop | Type | Default | |
|---|---|---|---|
size | md | lg | 'md' | |
aria-labelrequired | string | What 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. | |
container | HTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | null | Where 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.
| Prop | Type | Default | |
|---|---|---|---|
hint | string | Shown 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.
| Prop | Type | Default | |
|---|---|---|---|
icon | ReactNode | Drawn before the label. Size and colour are the product's. | |
hint | ReactNode | Drawn after the label, small and faint - where the item lives, such as a section name or a shortcut. | |
className | string | ||
childrenrequired | ReactNode |
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.
- v0.41.0
- docs read every props table from the component's types
ee3b159
- docs read every props table from the component's types
- v0.40.0
- v0.36.0
- fix set typed text at 16px under a finger
967db1d
- fix set typed text at 16px under a finger
- v0.35.0breaking
- feat one list of commands for keys, palette and sheet
dd9a125
- feat one list of commands for keys, palette and sheet
- v0.33.0breaking
- feat no-raw-button and no-arbitrary-scale
ac7a11a
- feat no-raw-button and no-arbitrary-scale
- v0.32.0breaking
- feat a layer for popups inside overlays, a body that scrolls alone
5244cfd
- feat a layer for popups inside overlays, a body that scrolls alone
- v0.18.2
- fix let a component turn off the focus ring, and dress the palette
0739669
- fix let a component turn off the focus ring, and dress the palette
- v0.18.1
- fix five defects a live run found, and none of the tests did
863226c
- fix five defects a live run found, and none of the tests did
2 earlier releases: every commit to the file.