Gesso
Actions

Command

A list of actions that filters as you type and runs from the keyboard, in a dialog or on the page.

Command is a list of actions you search by typing. It filters and ranks the rows as you type, moves a highlight with the arrow keys, and runs the highlighted row on Enter, so every action is a few keys away. CommandDialog puts it in a Dialog for a ⌘K menu, and a bare Command sits on the page. It is built on cmdk, and Combobox and Command palette are built on it.

In an app, bind it to ⌘K. The docs search already has that key here, so the button opens it.

Nothing run yet.

Usage

import { Plus } from '@phosphor-icons/react';
import {
  CommandDialog,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
} from '@/components/ui/command';

<CommandDialog open={open} onOpenChange={setOpen} title="Run a command">
  <CommandInput placeholder="Type a command…" />
  <CommandList>
    <CommandEmpty>No results.</CommandEmpty>
    <CommandGroup heading="Create">
      <CommandItem
        onSelect={() => {
          setOpen(false);
          createIssue();
        }}
      >
        <Plus />
        Create issue
      </CommandItem>
    </CommandGroup>
  </CommandList>
</CommandDialog>

The file exports Command, CommandDialog, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandSeparator, and CommandShortcut. Icons in a row are drawn at 16px, the highlighted row takes the bg-fill wash, and a disabled row dims and ignores the pointer.

Opening it with ⌘K

CommandDialog is controlled like Dialog, so hold open in state and flip it from a key listener. Running a row does not close the dialog, so call setOpen(false) in its onSelect, as the snippet above does.

import * as React from 'react';

React.useEffect(() => {
  function onKeyDown(event: KeyboardEvent) {
    if (event.key === 'k' && (event.metaKey || event.ctrlKey)) {
      event.preventDefault();
      setOpen((open) => !open);
    }
  }
  window.addEventListener('keydown', onKeyDown);
  return () => window.removeEventListener('keydown', onKeyDown);
}, []);

Escape and a click outside close it, so it has no close button unless you set showCloseButton, and focus goes back to whatever had it when the dialog opened. Its title names the dialog and its field, and its description describes the dialog. Neither is shown, and they default to "Command palette" and "Search for a command to run…", so set them to say what this one is for.

Filtering as you type

cmdk scores every row against what is typed, hides the rows that do not match, and sorts the rest best first, and a group with no matching row hides with its heading. Separators hide while there is a search, unless you set alwaysRender, and CommandEmpty shows when nothing matches. Try "prog", "urg", or "p2" below.

Nothing picked yet.

A row's value is what it is matched on, and when you leave it out it is read from the row's text, a CommandShortcut included. Set value when the row holds more than its name, and add keywords for the other words someone might type, which is how "p2" finds High above. value also names the row for cmdk, so keep it unique.

When the rows come from somewhere else, like a server, set shouldFilter={false} on Command and render only the rows you want, with the field controlled through CommandInput's value and onValueChange. filter swaps in your own scoring and keeps the rest. Command palette is built this way and shows a server search.

Without the dialog

A bare Command renders on the page, in a panel or a popover, and the demo above is one. Pass it a label, which names the field for assistive tech. It is a rounded bg-surface-raised box with no border, so add one when it sits on a surface of the same color.

CommandDialog hands its props to Dialog and not to cmdk, so loop, shouldFilter, and the other list props cannot reach the list from there. When you need them in a dialog, put a Command inside the kit Dialog yourself, as Command palette does.

Shortcuts and slots

CommandShortcut sets a key hint at the end of a row in muted text. Put a Kbd in it so the hint reads as a key. It only shows the key, and binding it is yours.

CommandInput takes leading for something after the search glyph, like the name of a list you have opened, trailing for something after the field, like a key hint, and wrapperClassName for the row that holds them. Command palette puts the open list's badge in leading and its esc hint in trailing.

import { Plus } from '@phosphor-icons/react';
import { CommandInput, CommandItem, CommandShortcut } from '@/components/ui/command';
import { Kbd } from '@/components/ui/kbd';

<CommandInput placeholder="Type a command…" trailing={<Kbd>esc</Kbd>} />

<CommandItem value="Create issue" onSelect={() => createIssue()}>
  <Plus />
  Create issue
  <CommandShortcut>
    <Kbd>C</Kbd>
  </CommandShortcut>
</CommandItem>

Props

CommandDialog takes Dialog's root props, open, defaultOpen, onOpenChange, and modal, plus these.

PropTypeDefaultWhat it does
titlestring"Command palette"Names the dialog and its field for assistive tech. It is not shown.
descriptionstring"Search for a command to run…"Describes the dialog for assistive tech. It is not shown.
showCloseButtonbooleanfalseAdds Dialog's close button in the corner.
classNamestringGoes on the dialog panel, so sm:max-w-2xl widens it.

CommandInput takes cmdk's input props, including a controlled value and onValueChange, plus these.

PropTypeDefaultWhat it does
leadingReactNodeDrawn after the search glyph, like the name of a list you have opened.
trailingReactNodeDrawn after the field, like a key hint.
wrapperClassNamestringClasses for the row around the glyph, the field, and the slots. className goes on the field.

The rest take cmdk's props as they are. On Command, label names the field, loop wraps the highlight at the ends, shouldFilter and filter replace the search, value and onValueChange control the highlighted row, disablePointerSelection keeps the pointer from moving the highlight, and vimBindings={false} turns off the Ctrl keys. On CommandItem, value, keywords, disabled, and onSelect, which gets the row's value.

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 field is a combobox that controls a listbox of option rows, and it points at the highlighted row with aria-activedescendant, so focus stays in the field while the highlight moves and a screen reader reads each row as it is reached.
  • ↑ and ↓ move the highlight, Home and End or ⌘↑ and ⌘↓ jump to the first and last row, Alt ↑ and Alt ↓ jump between groups, and Enter runs the highlighted row. Ctrl N or J and Ctrl P or K move it too, unless vimBindings is off. The highlight stops at either end unless loop is set, and the keys wait while an input method is composing.
  • Opening highlights the first row, and the list scrolls to keep the highlighted row in view. Moving the pointer over a row highlights it, and a click runs it.
  • A group's rows are a group named by its heading, a separator is a separator, and a disabled row is aria-disabled and skipped by the arrow keys.
  • cmdk names the field with a hidden label that holds Command's label. CommandDialog fills it with title, and a bare Command needs a label. The label wins over an aria-label on CommandInput, which only counts when label is empty.
  • CommandDialog names the dialog with title and describes it with description. On open, focus moves to the field and stays in the dialog, the page behind is hidden from assistive tech and does not scroll, and Escape or a click outside closes it.
  • Closing returns focus to whatever had it when the dialog opened, like the button that opened it, even though the dialog opens from your state rather than a Dialog trigger.
  • CommandEmpty is not a live region, so a screen reader is not told when nothing matches.
  • Under reduced motion the dialog opens and closes without its fade and pop. Rows never animate.
  • The highlight is only a background wash, which forced colors repaints, so in Windows high contrast the highlighted row has no mark of its own. Rows do not grow on a touch screen.

When to use something else

  • Command palette for an app-wide ⌘K with a lot to find. It adds scopes, lists inside the palette, and a preview of the highlighted row, and it leaves the search to you, so the rows can come from a server.
  • Combobox to choose a value for a field, like an assignee or a set of labels. It is this list in a Popover under its trigger, and it holds what was chosen, where Command runs an action and holds nothing.
  • Dropdown menu for a few actions on one thing, where typing to filter would not help.

Install

shadcn add @gesso/command

It brings Dialog for CommandDialog.

On this page