Gesso
Actions

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

KeyWhat happens
↑ ↓Move the highlight. It wraps at either end.
Home, EndJump 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 TabStep through the scopes, at the top level only.
A prefixTyped first, jumps to its scope, like > for actions.
EscLeave an open list, or close the palette at the top.
BackspaceIn an empty field inside a list, go back to the top.
Your ownonKeyDown 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.

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.

  1. Read me
  2. Release notes
  3. Rename
  4. Review queue
  5. Weekly review
  6. Preview pane
  7. 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

MomentWhat happens
Opening a listThe list's badge pops into the field on --duration-base, so you can see you have stepped inside.
Opening and closingDialog's own pop and fade.
Reduced motionThe 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.

PropTypeDefaultWhat it does
open, onOpenChangeboolean, (open) => voidControlled, like Dialog. The field, scope, and list reset each time it opens.
titlestringNames the dialog for assistive tech. Required.
placeholderstring"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.
prefixesRecord<string, string>Characters that jump to a scope when typed first.
listsRecord<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) => ReactNodeThe pane beside the list. Return null for a row with nothing to show, and leave it out for one narrower pane.
idleReactNodeThe preview when nothing is highlighted or the row has nothing to show.
emptyReactNode"Nothing matches."The list with nothing matching.
hints[key, what][][]More footer hints at the top level, like [['>', 'actions']].
onQueryChange(query) => voidFires when the text, scope, or list changes, and on opening. Fetch server results here.
onKeyDown(event, row) => voidSees every key before the palette does. preventDefault keeps the palette from handling it.
classNamestringGoes 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 title and described by placeholder, and the field's name follows the open list.
  • The field is a combobox over a listbox, as in Command, so focus stays in the field and the highlighted row is read through aria-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 md the 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 a Kbd.
  • 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-palette

It brings Command, Dialog, Toggle group, Kbd, and Badge, and the search helpers in command-palette.match.

On this page