Skip to content

PlCommandPalette

Everything an application can do, behind one field. The shape a keyboard-first product takes once it has more actions than a menu bar can hold: a reader types what they want instead of remembering where it was put.

React
tsx
import { PlCommandPalette } from 'plass-ui';

<PlCommandPalette
  items={[{ value: 'new', label: 'New document', group: 'File', shortcut: 'Mod+N' }]}
  onSelect={(item) => run(item.value)}
/>;
dart
import 'package:plass_ui/plass_ui.dart';

PlCommandPalette(
  open: open,
  onOpenChanged: (bool next) => setState(() => open = next),
  onSelect: (PlCommandItem item) => run(item.value),
  items: const <PlCommandItem>[
    PlCommandItem(value: 'new', label: 'New document', group: 'File', shortcut: 'Mod+N'),
  ],
);

Props

PropTypeDefaultDescription
items * readonly PlCommandItem[]Everything the palette can do
openbooleanWhether the palette is open. Use with onOpenChange for a controlled one
defaultOpenbooleanfalseWhether it starts open, for an uncontrolled one
onOpenChange(open: boolean) => voidCalled when it opens or closes
onSelect(item: PlCommandItem) => voidCalled when a command is run, after its own onSelect. The palette closes either way
shortcutstring | false'Mod+K'The keystroke that opens the palette, bound on the window. Written the way PlHotKeys writes them, so Mod is Command on a Mac and Control everywhere else. false binds nothing
widthnumber | stringHow wide the sheet may get. A number of pixels or any CSS length
maxHeightnumber | string320How tall the list may get before it scrolls
placeholderstring'Search commands'The placeholder in the field
emptyMessageReactNode'No commands found'The line where the rows would be, when nothing matched
labelstring'Command palette'The accessible name of the dialog, which has no visible title
sizeshared'xs' | 'sm' | 'md' | 'lg' | 'xl''md'The sheet's width, the field's height and the rows' type scale
colorshared'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info''primary'Semantic colour role. It reaches the highlight, the caret and the focus rings; the sheet is never dyed
densityshared'default' | 'compact''default'The height of a row, and nothing else
classNamestringClasses on the sheet, alongside the component's own rather than in place of them
styleCSSPropertiesInline styles on the sheet, applied over the custom properties it wrote
classNames{ backdrop?: string }Classes on the parts a className does not reach. backdrop is the scrim drawn behind the surface
PropTypeDefaultDescription
items * List<PlCommandItem>Everything the palette can do
open * boolWhether the palette is open. There is no uncontrolled mode: the thing that opens a palette is a key bound on the whole app, and an app that binds one already holds the state
onOpenChangedValueChanged<bool>?Called when it opens or closes
onSelectValueChanged<PlCommandItem>?Called when a command is run, after its own onSelect. The palette closes either way
shortcutString?'Mod+K'The keystroke that opens the palette, bound on the keyboard. Written the way PlHotKeys writes them, so Mod is Command on a Mac and Control everywhere else. null binds nothing
widthdouble?How wide the sheet may get. A number of pixels or any CSS length
maxHeightdouble320How tall the list may get before it scrolls
placeholderString'Search commands'The placeholder in the field
emptyMessageString'No commands found'The line where the rows would be, when nothing matched
labelString'Command palette'The accessible name of the dialog, which has no visible title
sizesharedPlassSizePlassSize.mdThe sheet's width, the field's height and the rows' type scale
colorsharedPlassColorPlassColor.primarySemantic colour role. It reaches the highlight, the caret and the focus rings; the sheet is never dyed
densitysharedPlassDensityPlassDensity.standardThe height of a row, and nothing else

No native attribute passes through: the palette renders a portalled dialog rather than an element in your tree, so there is nothing for a stray id or onClick to land on. className and style are the two that reach it, and both land on the sheet. The scrim behind it is what classNames.backdrop reaches.

PlCommandItem

PropTypeDefaultDescription
value * stringWhat identifies the command
label * stringWhat the row says, and what the query is matched against
descriptionReactNodeA second line under it — where the command goes, or what it changes
iconReactNodeA glyph before the label
shortcutstringThe keystroke that does the same thing, set at the end of the row. The palette does not bind it — the application does
groupstringThe heading this command sits under. Commands are drawn in the order they are given and a heading is drawn each time the group changes, so a group's commands have to be listed together
keywordsreadonly string[]Extra words the query is matched against but that are never drawn — the name somebody else's product gives the same command, an abbreviation, the word a reader would have searched for
disabledbooleanfalseIn the list but not runnable
onSelect() => voidWhat running it does
PropTypeDefaultDescription
value * StringWhat identifies the command
label * StringWhat the row says, and what the query is matched against
descriptionString?A second line under it — where the command goes, or what it changes
iconWidget?A glyph before the label
shortcutString?The keystroke that does the same thing, set at the end of the row. The palette does not bind it — the application does
groupString?The heading this command sits under. Commands are drawn in the order they are given and a heading is drawn each time the group changes, so a group's commands have to be listed together
keywordsList<String>const []Extra words the query is matched against but that are never drawn — the name somebody else's product gives the same command, an abbreviation, the word a reader would have searched for
disabledboolfalseIn the list but not runnable
onSelectVoidCallback?What running it does

What the shared axes mean across the library is in prop conventions.

Command palette or menu

  • It is not a PlMenu. A menu is a short list in one place, and every row is visible before you go looking for it.
  • It is not a PlCombobox either. What comes back is not a value. It is something happening.

Reach for it when the answer to "where is that command?" has become "I do not remember".

Examples

Groups, descriptions and keywords

Commands are drawn in the order they are given, and a heading appears each time group changes, so a group's commands have to be listed together. That is the whole layout rule, and it means the order on screen is the order in the array rather than something the component sorted behind your back.

keywords are matched and never drawn: the name somebody else's product gives the same command, an abbreviation, the word a reader would have searched for.

The filter folds case and combining marks, so cafe finds Café. Each command's searchable text is folded once per list rather than once per comparison. A normalize on every command for every character typed is exactly the cost that makes a palette feel slow.

React

shortcut

Two different things share the name, and only one of them is bound.

A row's shortcut is displayed, at the end of the row, with PlHotKeys. The palette does not bind it: the application already has, and a component that bound it too would be a second listener nobody asked for.

The palette's shortcut is bound, on the window, and defaults to Mod+K. It is read with the same Mod-aware vocabulary PlHotKeys draws, so the cap on the screen and the key that works cannot drift apart. false binds nothing.

size

The sheet's width, the field's height and the rows' type scale. The field sits one step above the control ladder, md is 48px, because a palette's field is not a control in a row of controls: it is the top of a sheet, and it is the only thing on screen.

density moves the row height and nothing else.

React

Controlled

Pass open with onOpenChange. The palette still asks, the keystroke fires onOpenChange(true), and does not open until the caller says so, which is what a route guard or a "not while the editor is busy" rule needs.

The query is dropped on the way out rather than on the way in, so the sheet never flashes the last search as it fades.

Differences from the React build

ReactFlutterWhy
open / defaultOpenopen, requiredThere is no uncontrolled mode: what opens a palette is a key bound on the whole app, and an app that binds one already holds the state.
shortcut: falseshortcut: nullDart's way of saying "bind nothing".
the list keys handled by Base UI's Autocompletehandled before the focus system, in the palette's own key handlerThe field has the focus and an EditableText consumes the arrow keys and Enter itself. Reading them first is the only way the field keeps every character while the list keeps its four keys.
a combobox with aria-activedescendanta field and a list of buttons, one marked selectedFlutter's semantics tree has no activedescendant. What survives is the thing that matters: the highlight is one mark, and it is announced on the row it is on.
the fold strips case and combining markscase onlyDart's core has no String.normalize, and this package has no dependencies.
width, maxHeight as a number or a CSS lengthdoubleThere is no second unit to name.
className, styleThere is no class list and no style attribute to pass through.

Accessibility

  • The sheet is a dialog with a focus trap, a scrim, Esc to close, and focus returned to wherever the reader was. It has no visible title, so label is its accessible name.
  • The field is a combobox and the list is its listbox, wired with aria-activedescendant by Base UI, so the arrow keys move a highlight without moving focus, and the field keeps every keystroke.
  • The highlight is one mark: the pointer and the arrow keys move the same thing, so a reader is never looking at two highlighted rows wondering which Enter would run.
  • A group heading is role="presentation". It is a visual grouping of the same list, not a second list.
  • A disabled command stays in the list and cannot be run. An option that vanishes when it cannot be chosen is one the reader will keep looking for.
  • The whole thing is portalled to the end of <body>, and the backdrop and the viewport carry .plass-portal, which is where a host that scopes a CSS reset hangs the same reset.

Released under the MIT License