Command palette

A recipe, not a package namespace. Dialog holds the palette, and useCombobox owns the input, filtering, the active option and ids. There is no CommandPalette.* export.

Use the dialog presentation and spread getDialogProps() on Dialog.Root. When the palette opens, the hook clears the input and makes the top row active, and a command chosen while the palette closes does not run again. Keep value at null and run the command in onValueChange, so choosing one leaves nothing selected.

const cb = useCombobox({
  options: commands,
  presentation: 'dialog',
  value: null,
  onValueChange: (value: string | null) => { if (value) run(value) },
})
useHotkey(['Control+K', 'Meta+K'], () => !cb.open && cb.setOpen(true))

<Button.Root {...cb.getTriggerProps()} aria-keyshortcuts="Control+K Meta+K">
  Search commands
</Button.Root>
<Dialog.Root {...cb.getDialogProps()} aria-label="Commands">
  <Field.Root id={cb.ids.input} aria-label="Command">
    <Field.TextInput {...cb.getInputProps()} placeholder="Type a command" />
  </Field.Root>
  <OptionList.Root {...cb.getListboxProps()} hidden={cb.isEmpty}>
    {cb.items.map((item) => (
      <OptionList.Option key={item.id} {...cb.getOptionProps(item)} />
    ))}
  </OptionList.Root>
  <Text role="status">{cb.isEmpty ? 'No matching commands.' : ''}</Text>
</Dialog.Root>
Create invoice
Add customer
Open billing settings
Switch theme
Sign out

Press Ctrl+K or ⌘K, or use the button.

The shortcut is useHotkey, which opens the palette on Ctrl+K or ⌘K. aria-keyshortcuts on the trigger tells assistive technology about it. Escape and a click on the backdrop close the dialog, and focus goes back to the element that had it when the palette opened, or to the trigger when nothing had focus. The empty message sits in a role="status" region, so screen readers announce it.

Accessible composition

Todae handles: Dialog is a native modal <dialog>, so the page behind it is inert while it is open, and it closes and returns focus as described above. useCombobox makes the input a role="combobox" with aria-activedescendant on the active command, moves it with the arrow keys, Home and End, and chooses it with Enter. getTriggerProps() gives the trigger aria-haspopup="dialog", aria-expanded and aria-controls.

You supply: A name for the dialog: getDialogProps() sets aria-label="Options", so pass your own after the spread, as aria-label="Commands" does above. A name for the input, here aria-label on Field.Root. Visible trigger text, and aria-keyshortcuts for the shortcut. The empty message in a role="status" element that stays mounted. A result people can perceive after a command runs, such as a status message, when the command has no visible effect.

Watch for: Your aria-label placed before {...cb.getDialogProps()}: the spread's "Options" then wins. Unmounting the list when nothing matches; hide it with hidden, so aria-controls still points at it. A palette reachable only by the shortcut.

When to use

When: One place to search and run actions across an app.

When not: Picking a value for a form field → the Combobox recipe. A short, fixed list of actions → Menu.