CommandPalette

A quick-open dialog that finds a document, a page, a person or a command by name.

StableSince 1.0.0
On this page

Import

import { CommandPalette } from '@envsemble/envui';

Usage

The application owns the items and what choosing one does.

  • open opens the palette, such as from a shortcut and from a visible control.
  • onOpenChange receives false when Esc, a press on the backdrop or a choice closes it, and why, as a dialog's does: escape-key, outside-press, or item-press for a choice.
  • The application filters and ranks the items itself, from the text in inputValue and onInputValueChange, and clears that text as the palette closes.
  • onAction runs the chosen item, and closeOnAction, true by default, closes the palette after it.
  • aria-label names the dialog, its search field and its list.
  • placeholder shows in the empty field.
  • suggestedItems show in place of items while the field is empty, and the empty state in place of the list when there are no options.
GoToDocument.tsx
import { useMemo, useState } from 'react';
import { Button, CommandPalette, Column, Text, type CommandPaletteItem } from '@envsemble/envui';
import { IconDescription } from '@envsemble/envui/icons/Description';
import { IconSearch } from '@envsemble/envui/icons/Search';

const documents = [
  { name: 'Roadmap.md', folder: 'Plans', recent: true },
  { name: 'Pricing research.md', folder: 'Research', recent: true },
  { name: 'Launch checklist.md', folder: 'Plans/Northwind', recent: true },
  { name: 'Interview notes.md', folder: 'Research/Interviews', recent: false },
  { name: 'Support handbook.md', folder: 'Guides', recent: false },
  { name: 'Quarterly review.md', folder: 'Reports', recent: false },
];

export default function GoToDocument() {
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState('');
  const [opened, setOpened] = useState('');

  // The application filters and ranks the items; here, every word of the query must match.
  const items = useMemo<CommandPaletteItem[]>(() => {
    const words = query.toLowerCase().split(/\s+/).filter(Boolean);
    return documents
      .filter((document) =>
        words.every((word) => `${document.name} ${document.folder}`.toLowerCase().includes(word)),
      )
      .map((document) => ({
        id: `${document.folder}/${document.name}`,
        label: document.name,
        description: document.folder,
        icon: <IconDescription />,
        group: !query && document.recent ? 'Recently opened' : 'Documents',
      }));
  }, [query]);

  return (
    <Column gap={3} align="center">
      <Button variant="secondary" startIcon={<IconSearch />} onClick={() => setOpen(true)}>
        Search
      </Button>
      <CommandPalette
        open={open}
        onOpenChange={(next) => {
          setOpen(next);
          if (!next) setQuery('');
        }}
        aria-label="Go to document"
        placeholder="Search documents by name or folder"
        inputValue={query}
        onInputValueChange={setQuery}
        items={items}
        onAction={(item, { modifier }) =>
          setOpened(`${modifier ? 'Opened to the side' : 'Opened'}: ${item.id}`)
        }
        emptyLabel="No matching documents"
        hints={[
          { keys: ['Enter'], label: 'Open' },
          { keys: ['Ctrl', 'Enter'], label: 'Open to the side' },
          { keys: ['Esc'], label: 'Close' },
        ]}
      />
      <Text variant="caption" emphasis="muted" role="status">
        {opened}
      </Text>
    </Column>
  );
}

Items

Each CommandPaletteItem has an id and a label, with textValue for a label that isn't plain text.

  • description is muted text under the label, such as a folder.
  • icon is a decorative icon before it.
  • end is content at the end, such as a Kbd shortcut or a Badge.
  • disabled shows an item that can't be chosen.
  • group names its group, whose heading shows where the group changes between items, such as Recently opened while the query is empty.

Suggestions and the empty state

suggestedItems show while the field is empty, or holds only spaces, in place of items, such as recent documents or suggested pages with a group heading; they are options like any other, highlighted, chosen with Enter or Ctrl+Enter and passed to onAction.

With no options, the empty state shows in place of the list as the polite status: one muted line at an option's height, its title and then its description after a dot, so the palette keeps about its height as results come and go.

  • While the field is empty: Type to search, with no description; emptyDescription says what the palette finds.
  • When the text finds nothing: No results, and a hint that quotes the trimmed text.
  • While results load: the loading label, with a spinner at the search field's end, which also shows while results already found stay.
  • emptyLabel and emptyDescription replace the title and the description in either state, null or false hiding the description, and emptyIcon puts an icon at the start of the line, where an option's sits, such as an error icon. Pass a value that depends on the query to change one state only.
  • The icon is decorative and hidden from assistive technology; the title and description are announced.
const typed = query.trim() !== '';

<CommandPalette
  aria-label="Go to document"
  inputValue={query}
  onInputValueChange={setQuery}
  items={matches}
  // Recent documents, each with group: 'Recently opened'.
  suggestedItems={recent}
  emptyLabel={typed ? 'No matching documents' : undefined}
  emptyDescription={typed ? undefined : 'Find a document by its name or folder.'}
  {...props}
/>;

Commands and key hints

onAction receives the item and { modifier }, true while Ctrl or Cmd was held, such as to open beside. hints show keys below the list as Kbd, such as { keys: ['Enter'], label: 'Open' }, and footer holds other content after them, as the RunCommand example does. A hint with Enter in its keys hides while there is no option to choose, as results load or when nothing matches.

<CommandPalette
  open={open}
  onOpenChange={setOpen}
  aria-label="Run a command"
  inputValue={query}
  onInputValueChange={setQuery}
  items={[
    { id: 'new', label: 'New document', icon: <IconNoteAdd />, end: <Kbd keys={['Ctrl', 'N']} /> },
    { id: 'automations', label: 'Create an automation', end: <Badge tone="primary">New</Badge> },
  ]}
  onAction={(item, { modifier }) => run(item.id, { beside: modifier })}
  hints={[{ keys: ['Enter'], label: 'Run' }]}
  footer="Shortcuts work anywhere in the workspace."
/>

The button that opens it

The button is a Button with variant="secondary", IconSearch as its startIcon, and a Kbd of the keys in its end slot, as the InSidebar example does.

  • aria-hidden on the Kbd and aria-keyshortcuts on the button announce the shortcut once.
  • Its onClick opens the palette.
  • With fullWidth it fills its container, such as a NavigationDrawer's header.
<Button
  variant="secondary"
  fullWidth
  startIcon={<IconSearch />}
  end={<Kbd keys={[modifier.label, 'K']} aria-hidden />}
  aria-keyshortcuts={`${modifier.key}+K`}
  onClick={() => setOpen(true)}
>
  Search
</Button>

The shortcut

The application listens for the shortcut once, at its root, however many buttons show it, and opens the same palette the button does. It skips an event whose defaultPrevented is set, so a key press that something else already handled, such as an editor's own shortcut, is left alone.

useModifierKey(), on Kbd's page, gives the platform's modifier.

  • Its key, Meta or Control, for aria-keyshortcuts and the listener.
  • Its label, ⌘ or Ctrl, for Kbd.
  • It is Ctrl on the server and while the page hydrates, so server-rendered markup matches, and becomes ⌘ on Apple platforms right after.
  • Don't read navigator while rendering: it doesn't exist on the server, and markup that differs from the server's fails to hydrate.
const modifier = useModifierKey();

useEffect(() => {
  const onKeyDown = (event: KeyboardEvent) => {
    const pressed =
      modifier.key === 'Meta' ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey;
    if (event.defaultPrevented || !pressed || event.altKey || event.shiftKey) return;
    if (event.key.toLowerCase() !== 'k') return;
    event.preventDefault();
    setSearchOpen((open) => !open);
  };
  addEventListener('keydown', onKeyDown);
  return () => removeEventListener('keydown', onKeyDown);
}, [modifier.key]);

Icon only

In a narrow header, the button is an IconButton with variant="ghost", aria-label="Search" and the same aria-keyshortcuts, inside a Tooltip with side="bottom" whose content holds the label and the Kbd, as the IconOnly example does.

<Tooltip
  side="bottom"
  content={
    <Row render={<span />} align="center" gap={2}>
      Search <Kbd keys={[modifier.label, 'K']} />
    </Row>
  }
>
  <IconButton
    variant="ghost"
    icon={<IconSearch />}
    aria-label="Search"
    aria-keyshortcuts={`${modifier.key}+K`}
    onClick={() => setOpen(true)}
  />
</Tooltip>

Composition

The palette is env-command-palette: its search field, env-command-palette-search, a large ghost TextField kept solid on the frosted palette, with its input, env-command-palette-input, and the close button shown under a coarse pointer, env-command-palette-close, in env-command-palette-header, the group headings, env-command-palette-group-label, the items, env-command-palette-item, the key hints in env-command-palette-footer, and the empty state, env-command-palette-status with data-empty: its icon, env-command-palette-empty-icon, title, env-command-palette-empty-title, and description, env-command-palette-empty-description. className, style, ref and data attributes reach the surface.

The button that opens it is Button's: env-button, its icon, env-button > env-icon, its label, and the keys in its end slot, env-button-end. The icon-only button is an env-icon-button in a tooltip.

Customize

Its popup is frosted, as every popup is: --env-popup-opacity, --env-popup-blur and --env-popup-shadow, set on a theme, change it, as Popover describes.

Words in code

WordsIn code
SuggestionssuggestedItems, with a group such as Suggested
Recent itemssuggestedItems, with a group such as Recently opened
Type to searchThe empty state with an empty query: emptyDescription
No resultsemptyLabel, emptyDescription and emptyHint
With an iconemptyIcon

Accessibility

You provide

  • The shortcut that opens the palette, and a visible control that opens it too.
  • aria-keyshortcuts on that control for the platform, `${modifier.key}+K` from useModifierKey(), and aria-hidden on the keys it shows, so the shortcut is announced once.
  • An aria-label, and the items, filtered and ranked.
  • textValue for labels that aren't plain text.

envUI provides

  • A modal dialog named by aria-label, with focus in its search field, kept inside and returned on close.
  • Under a coarse pointer, such as a phone's, a close button after the search field in place of Esc, and no keys: neither the items' shortcuts nor the key hints show.
  • The field as a combobox over a listbox, with the highlighted option as its active descendant.
  • A polite count of results as they change.
  • The empty state's title and description in that same polite status, with its icon hidden from assistive technology.
  • For the button that opens it, a native button named by its label, or by aria-label when it shows only its icon, and the keys as Kbd, in the button's content color on every variant.

Props

CommandPalette

A quick-open dialog near the top of the window: a search field over a list of options the application filters and ranks, built on Base UI's Autocomplete inside a dialog. The arrow keys move the highlight while typing continues, and the number of results is announced as it changes. className, style, ref and data attributes go to the dialog's surface.

PropTypeDescription
openrequired
boolean
Whether the palette is open; the application opens it, such as from a shortcut.
onOpenChangerequired
(open: boolean, details: CommandPaletteOpenChangeDetails) => void
Called with false, and why, when Esc, a press on the backdrop or a choice closes the palette.
aria-labelrequired
string
Accessible name of the dialog, the search field and the list.
inputValuerequired
string
The text in the search field.
onInputValueChangerequired
(inputValue: string) => void
Receives the text as people type.
itemsrequired
readonly CommandPaletteItem[]
Filtered and ranked by the application, in display order.
suggestedItems
readonly CommandPaletteItem[]
Shown in place of items while the search field is empty, such as recent documents or suggested pages under a group heading; options like any other.
onActionrequired
(item: CommandPaletteItem, details: CommandPaletteActionDetails) => void
Enter or a press on an option; Ctrl or Cmd sets modifier.
closeOnActionDefault true
boolean
Close after an option is chosen.
placeholder
string
Shown in the empty search field, such as "Search documents by name or folder".
loadingDefault false
boolean
The results are on their way, such as from a server. A spinner shows at the search field's end; with no results to show yet, the empty state's line says loadingLabel, and results already shown stay, marked busy.
loadingLabelDefault 'Searching…', or EnvUIProvider's messages.commandPalette.loading
ReactNode
Shown and announced while loading.
emptyLabelDefault 'Type to search' while the search field is empty and 'No results' when its text finds nothing, or EnvUIProvider's messages.commandPalette.emptyQuery and empty
ReactNode
The empty state's title, the start of the one muted line shown in place of the list when there are no options, and announced.
emptyDescriptionDefault None while the search field is empty; when its text finds nothing, 'Nothing matches “…”. Check the spelling or try other words.', or messages.commandPalette.emptyHint
ReactNode
Text after the empty state's title on its line, such as what the palette searches; null or false shows none.
emptyIcon
ReactNode
A decorative icon at the start of the empty state's line, in an option's icon place, such as an error icon when the search failed; none by default.
hints
readonly CommandPaletteHint[]
Key hints below the list, such as { keys: ['Enter'], label: 'Open' }. A hint with Enter in its keys hides while there is no option to choose.

Also accepts the native props of <div>, such as ref, className and aria-* attributes.

Part names

Each part's class and state attributes are public and versioned, for an application's CSS to style.

env-command-palette
The command palette's dialog.
env-command-palette-close
The close button after the search, shown under a coarse pointer.
env-command-palette-empty-description
The empty state's text after its title, on its line.
env-command-palette-empty-icon
The empty state's icon, at the start of its line.
env-command-palette-empty-title
The empty state's title.
env-command-palette-footer
The footer with the keyboard hints.
env-command-palette-footer-content
The content of the footer.
env-command-palette-group
A group of commands.
env-command-palette-group-label
A group's label.
env-command-palette-header
The row of the search field and the close button.
env-command-palette-hint
One keyboard hint.
env-command-palette-input
The search input.
env-command-palette-item
One command.
env-command-palette-list
The list of commands.
env-command-palette-positioner
The palette's positioner.
env-command-palette-search
The search field, a ghost TextField around the search input.
env-command-palette-status
The empty state in place of the list, and the announcement of the number of results.
data-empty
Present on env-command-palette-status
data-ending-style
Any value, or present
data-starting-style
Any value, or present

Strings

EnvUIProvider's messages overrides each, and a component's own label prop wins over it.

messages.commandPalette.emptyQuery
"Type to search" The empty state's title while the search field is empty and nothing shows in the list.
messages.commandPalette.empty
"No results" The empty state's title when the text in the search field finds nothing.
messages.commandPalette.emptyHint
({ query }) => string Under that title: what found nothing, and what to try, such as Nothing matches “roadmap”.
messages.commandPalette.loading
"Searching…" The empty state's title while results are on their way, and what is announced meanwhile.
messages.commandPalette.results
({ count, countText }) => string Announced as the results change, such as 3 results; `countText` is the count in the locale's digits.

Changelog

1.0.0

Opens from a Button with the shortcut's keys in its new end slot, in place of the SearchTrigger component. Rebuilt on Base UI's autocomplete: inputValue, onInputValueChange, onAction and closeOnAction for query, onQueryChange, onSelect and closeOnSelect, and CommandPaletteActionDetails; items take description, end and disabled; options are list rows; the number of results is announced; className, style, ref and data attributes reach the surface. A group's label, env-command-palette-heading, is env-command-palette-group-label, as in Menu.

1.0.0-prerelease.6

The palette fades in with --env-motion-ease-enter, so it shows from its first frames rather than late, while it drops into place as before.

An item's shortcut is muted text in one unit, such as Ctrl+N, rather than a filled chip for each key; the key hints below the list keep their keys.

The empty state is one muted line at an option's height, its title and description after a dot, rather than an icon on a circle over a centred title, so the palette keeps about its height as results come and go; it shows an icon only when emptyIcon gives one. While results load, a spinner turns at the search field's end, rather than in place of its icon or the empty state's.

While it holds the caret, the search's edge takes the focus color as a hairline, rather than a ring that read as a second frame.

A key hint for Enter hides while there is no option to choose, as results load or when nothing matches.

Under a coarse pointer the palette hides the keys, the shortcuts in its items and the key hints of its footer, and shows a close button after the search field, env-command-palette-close, for touch screens with no Esc; the two share a row, env-command-palette-header.

1.0.0-prerelease.5

onOpenChange receives why the palette closes, CommandPaletteOpenChangeDetails, as a dialog's does.

defaultItems is suggestedItems: default names an uncontrolled starting value everywhere else.

The palette is frosted: the popup's color lets a little of what is behind through, blurred, with a soft shadow and no outline edge but a faint hairline in dark, and is solid with its edge at high contrast and with reduced transparency. Its search is a large ghost TextField, opaque on the neutral fill with no outline, whose edge returns at high contrast; its headings are small and muted, its rows and keys are the popup lists', the keys side by side with the + read but not shown, and its hints are in the small label size. Its options take the text color rather than the controls', lie one under the next with no space between them, and its headings sit a little ahead of the rows.

1.0.0-prerelease.4

An empty state with an icon, a title and a description, which tells an invitation before people type from no results; emptyDescription, emptyIcon and messages.commandPalette.emptyQuery and emptyHint. defaultItems show before people type.

1.0.0-prerelease.3

The search trigger pattern is part of this page, as the button and the shortcut that open the palette; the pattern's address opens it.