Command palette
Two panes on Command and Dialog. Scopes that Tab cycles, matched words marked, sub-lists, and a preview of the highlighted row.
A ⌘K palette for an app with a lot to find. The list sits on the left and a preview of the highlighted row sits on the right, so a glance tells you whether it is the page you meant before you open it. It is built on Command and Dialog, and it does no searching of its own: you hand it the rows for what is typed, and it handles the keys, the highlight, and the layout.
Nothing run yet.
In an app, bind it to ⌘K. Here the docs search already has that key, so the button opens it.
Try > to jump to actions, Tab to step through the scopes, and Move to, which opens a list
of places inside the palette. The preview follows the highlight, and below md it drops away
so the list keeps the width.
Usage
import {
CommandPalette,
PaletteMark,
rankByName,
} from '@/components/ui/command-palette';
<CommandPalette
open={open}
onOpenChange={setOpen}
title="Search the workspace"
scopes={[
{ id: 'all', label: 'All' },
{ id: 'pages', label: 'Pages' },
]}
prefixes={{ '>': 'actions' }}
lists={{ move: { label: 'Move to', placeholder: 'Find a place' } }}
sections={({ text, scope, list }) => [
{
heading: 'Pages',
rows: rankByName(pages, text, (page) => page.title).map((page) => ({
id: page.id,
label: <PaletteMark text={page.title} query={text} />,
href: `/pages/${page.id}`,
item: page,
onSelect: () => router.push(`/pages/${page.id}`),
})),
},
]}
preview={(row) => <PagePreview page={row.item} />}
/>Keys
| Key | What happens |
|---|---|
| ↑ ↓ | Move the highlight. It wraps at either end. |
| Home, End | Jump to the first or last row. |
| Alt ↑ ↓ | Jump to the previous or next section. |
| ↵ | Run the highlighted row. The palette closes unless the row opened a list. |
| ⌘↵ or Ctrl↵ | Open the highlighted row's href in a new tab, and keep the palette open. |
| Tab, Shift Tab | Step through the scopes, at the top level only. |
| A prefix | Typed first, jumps to its scope, like > for actions. |
| Esc | Leave an open list, or close the palette at the top. |
| Backspace | In an empty field inside a list, go back to the top. |
| Your own | onKeyDown sees every key first, with the highlighted row, like ⌘⇧, to copy its link. |
The footer shows the keys that work at the moment: ⌘↵ only when a row has an href, Tab
only when there are scopes, and your hints after them at the top level. Inside a list it
shows only choose and back.
Rows come from you
sections runs each time the palette renders, so on every keystroke, with the text less any
prefix, the scope in effect, and the open list, and the rows always match what is typed. At
the top level the text is trimmed, and inside a list it arrives as typed. When the
highlighted row drops out, the first row takes the highlight.
A row has an id, a label, and an onSelect, plus an optional sub line, icon, aside,
href, and item, which is whatever the preview needs. Row ids are unique across every
section, so prefix them by kind, like task:RAM-142. The palette does not care what an item
is: type CommandPalette<T> with a union when the list mixes kinds.
preview draws the pane beside the list for the highlighted row. Return null for a row
with nothing to show and the pane shows idle, and leave preview out for one narrower pane.
Scopes and lists
scopes sit over the list as tabs, and the first is where the palette starts. Tab and
Shift Tab step through them and a click picks one. A prefix from prefixes, typed first,
jumps to its scope and the tabs follow it, and choosing a scope drops a typed prefix from the
field. Prefixes only count at the top level.
A row's onSelect gets close and openList(id, query). Open a list for anything that needs
a second choice, like a place to move to, and prefill the field when the choice starts from
something, like a title to rename. Inside a list its label rides in the field as a badge,
its placeholder, when it has one, replaces the field's, and the scope tabs step aside until
you go back.
Server search
When the rows come from a server, fetch in onQueryChange and let sections read what came
back. The demo does this with a stand-in that answers after 150ms, so you can watch empty
say "Searching…" in the meantime.
const [found, setFound] = useState<JumpResults>(EMPTY);
const [searching, setSearching] = useState(false);
<CommandPalette<Result>
onQueryChange={({ text, scope }) => {
setSearching(true);
jump(text, scope).then((results) => {
setFound(results);
setSearching(false);
});
}}
sections={({ scope }) => sectionsFrom(found, scope)}
empty={searching ? 'Searching…' : 'Nothing matches.'}
preview={(row) => <ResultPreview result={row.item} />}
/>A preview that needs more than the row carries can fetch for itself, since preview returns
any element.
Search helpers
The palette leaves ranking to you, and ships a ranking so you do not have to write one.
rankByName(items, query, name) puts whole-name matches first, then the start of the name,
then the start of a word, then anywhere, then every word somewhere, and keeps your order among
equals. name may return several strings, like a title and an alias, and the best one
counts. An empty query returns nothing, and a fourth argument caps the results at 12 by
default. PaletteMark marks each word of the query in a label or a snippet.
- Read me
- Release notes
- Rename
- Review queue
- Weekly review
- Preview pane
- Press kit
For the server, import the helpers from @/components/ui/command-palette.match, which is not
a client module. It also has paletteWords, the query split into lowercase words, and
markSegments, the text split around the matches, for marking them your own way.
Motion
| Moment | What happens |
|---|---|
| Opening a list | The list's badge pops into the field on --duration-base, so you can see you have stepped inside. |
| Opening and closing | Dialog's own pop and fade. |
| Reduced motion | The badge just appears, and the dialog opens and closes without its pop and fade. |
Props
CommandPalette takes only these. It renders its own Command and Dialog, so their props do
not pass through.
| Prop | Type | Default | What it does |
|---|---|---|---|
open, onOpenChange | boolean, (open) => void | Controlled, like Dialog. The field, scope, and list reset each time it opens. | |
title | string | Names the dialog for assistive tech. Required. | |
placeholder | string | "Search" | The field's placeholder and name at the top level, and the dialog's description. |
scopes | { id, label }[] | Tabs over the list. The first is where it starts. | |
prefixes | Record<string, string> | Characters that jump to a scope when typed first. | |
lists | Record<string, { label, placeholder? }> | Sub-lists a row can open. The label rides in the field as a badge. | |
sections | (query) => { heading, rows }[] | The rows, in order. Required. | |
preview | (row) => ReactNode | The pane beside the list. Return null for a row with nothing to show, and leave it out for one narrower pane. | |
idle | ReactNode | The preview when nothing is highlighted or the row has nothing to show. | |
empty | ReactNode | "Nothing matches." | The list with nothing matching. |
hints | [key, what][] | [] | More footer hints at the top level, like [['>', 'actions']]. |
onQueryChange | (query) => void | Fires when the text, scope, or list changes, and on opening. Fetch server results here. | |
onKeyDown | (event, row) => void | Sees every key before the palette does. preventDefault keeps the palette from handling it. | |
className | string | Goes on the dialog panel. |
Accessibility
- The field's placeholder is in
text-fg-muted, the stronger ink, because a command field usually has no visible label and the placeholder is the one instruction it shows. - The dialog is named by
titleand described byplaceholder, and the field's name follows the open list. - The field is a
comboboxover alistbox, as in Command, so focus stays in the field and the highlighted row is read througharia-activedescendant. - A polite live region says the scope or the list each time it changes, since Tab moves it without moving focus.
- The scope tabs are a radio group named "Search in", and the preview is a region named
"Preview". Below
mdthe preview is hidden for everyone, assistive tech included. - At the top level with scopes, Tab and Shift Tab step the scopes forward and back instead of leaving the field.
- Esc always has a way out: it leaves an open list, or closes the palette at the top. Closing returns focus to whatever had it when the palette opened, like the button that opened it.
- Matches are
<mark>elements, and every hint in the footer is aKbd. - Under reduced motion the badge and the dialog appear without moving.
- The highlighted row is a background wash, as in Command, so in forced colors it has no mark of its own. Rows do not grow on a touch screen.
When to use something else
- Command for a list of actions that cmdk filters for you, in a dialog or on the page, when there is one kind of thing to find and no preview to show.
- Combobox to choose a value for a field, like an assignee or a set of labels. It is Command in a Popover under its trigger, and it holds what was chosen, where the palette runs an action and closes.
Install
shadcn add @gesso/command-paletteIt brings Command, Dialog,
Toggle group, Kbd, and
Badge, and the search helpers in
command-palette.match.