Command
Command palette / ⌘K menu — filterable, keyboard-driven action launcher with inline and modal modes, grouped items, async states, scope toolbar, rich-content panel, and footer key hints.
Playground
Installation
pnpm add @tessinaui/uiUsage
import {
Command,
useCommandShortcut,
} from "@tessinaui/ui";Inline command palette
Embed the palette directly in the page. The list is always visible — typing into the input filters items live.
<Command.Root size="md" rounded="md">
<Command.Input placeholder="Type a command or search…" shortcut="⌘K" />
<Command.List>
<Command.Empty />
<Command.Group heading="Suggestions">
<Command.Item value="dashboard" leadingIcon={<LayoutDashboard />}>
<Command.ItemTitle>Dashboard</Command.ItemTitle>
<Command.Shortcut>⌘D</Command.Shortcut>
</Command.Item>
<Command.Item value="settings" leadingIcon={<Settings />}>
<Command.ItemTitle>Settings</Command.ItemTitle>
</Command.Item>
</Command.Group>
</Command.List>
</Command.Root>Modal command palette
Wrap the same content in Command.Dialog to make a ⌘K overlay. Pair with useCommandShortcut("k", …) for a global keyboard trigger. The dialog stays a centered card at every size; below md it sits just under the safe area and caps at min(45dvh, 24rem) so the input, the results and the footer all stay above a virtual keyboard (mobilePresentation="fullscreen" opts into the takeover instead).
function App() {
const [open, setOpen] = useState(false);
useCommandShortcut("k", () => setOpen((o) => !o));
return (
<Command.Dialog open={open} onOpenChange={setOpen}>
<Command.Input placeholder="Search…" />
<Command.List>
<Command.Empty />
<Command.Group heading="Actions">
<Command.Item value="new-file" onSelect={() => createFile()}>
<Command.ItemTitle>New file</Command.ItemTitle>
<Command.Shortcut>⌘N</Command.Shortcut>
</Command.Item>
</Command.Group>
</Command.List>
<Command.Footer>
<Command.FooterHint shortcut="enter">To select</Command.FooterHint>
<Command.FooterHint shortcut="arrows">To navigate</Command.FooterHint>
<Command.FooterHint shortcut="escape" appearance="ghost">To close</Command.FooterHint>
</Command.Footer>
</Command.Dialog>
);
}Items with rich content
Items can hold a title, optional description, inline trailing metadata, a breadcrumb path, and a trailing shortcut. Command.HighlightMatch emphasises the query inside any text.
<Command.Item value="settings-team" keywords={["prefs"]} leadingIcon={<Users />}>
<Command.ItemTitle>
<Command.HighlightMatch>Team settings</Command.HighlightMatch>
</Command.ItemTitle>
<Command.ItemDescription>Invite or remove members</Command.ItemDescription>
<Command.ItemMeta>workspace</Command.ItemMeta>
<Command.Shortcut>⌘T</Command.Shortcut>
</Command.Item>
<Command.Item value="docs-deep-link">
<Command.Crumbs crumbs={["Components", "Form", "Field"]} />
</Command.Item>Examples
Default
An inline command palette with grouped suggestions, icons, and shortcuts. With text in the field, Esc clears it (with it empty, a wrapping dialog's Esc dismisses).
Modal dialog
Command.Trigger + useCommandShortcut + a live result count in the footer.
Mobile presentation
mobilePresentation="fullscreen" opts below md into the takeover every surveyed mobile system ships, with a Cancel button composed from Command.Close in the input's trailing slot. It is opt-in rather than the default because a full-height sheet pins its footer to the layout viewport's bottom edge — and on iOS the keyboard covers that edge without shrinking dvh, so footer hints go out of view. Keep the dismiss affordance in the input row when you use it.
Anchored
The palette hanging off a page control via the house Popover — Command.Root is position-agnostic.
Scope toolbar
Command.Toolbar hosts a result-type switcher between the input and the results. It renders outside the listbox, so interactive children are legal.
Match highlighting
Command.HighlightMatch wraps query occurrences in a de-yellowed <mark>.
Async search
External filtering (disableFiltering), the input's loading spinner, skeleton result rows, and Command.Error with a forceMount retry item — type fail to see the error path.
Actionable empty state
The default Command.Empty copy echoes the query. Recovery actions are pinned options (forceMount items) — a button inside the listbox would be an aria-required-children violation.
Recents chips
Command.Chips with per-chip remove (Delete/Backspace on the focused chip) and a legal "Clear all" action — the row is one Tab stop with arrow-key navigation.
Filtering
keywords aliases, value inference from the visible title, and a custom filter.
Pinned AI row
A forceMount item stays visible under every query — the ask-AI pattern.
Nested pages
The cmdk-endorsed pages recipe on plain composition: a page stack in consumer state, Backspace on an empty input pops, the trail renders as chips.
Rich content layout
Cards, app icons and sections compose inside Command.Panel — a scroll column with no listbox role, so its buttons are valid and Tab-reachable. The sidebar is a real tablist (one Tab stop, Up/Down arrows).
Rich items
Items with descriptions, avatars, metadata, and breadcrumb paths.
Sizes
Five sizes from xs to xl, each with proportional padding, type, and icons. On phones the input floors at 16px text (iOS zoom) and rows at 44px.
Rounded
Six corner steps from none to full. The scale follows the §2 panel ladder — full caps at 24px, the panel maximum.
Intent
A semantic accent on the container border — none, error, warning, success, or info.
States
Empty (query echo), loading (spinner and skeleton variants), error, and the whole-component skeleton.
API Reference
Command.Root / Command.Dialog props
Both accept the same visual props. Command.Root renders inline; Command.Dialog renders inside a Base UI Dialog with backdrop and focus trap.
| Prop | Type | Default | Description |
|---|---|---|---|
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Input height, padding, and typography scale |
rounded | "none" | "sm" | "md" | "lg" | "xl" | "full" | "full" (Dialog) / "lg" (Root) | Container radius on the §2 panel ladder (8→24px; full caps at 24px) |
width | "narrow" | "default" | "wide" | "default" | Max-width |
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Semantic accent on the container border |
dir | "ltr" | "rtl" | — | Stamped only when set; unset palettes inherit the document direction |
filter | (value, query) => boolean | substring | Match predicate, applied to value and each keyword |
disableFiltering | boolean | false | Hand result control to the consumer (async/server search) |
value / defaultValue | Value | — | Controlled / uncontrolled selected item value (from Base UI Combobox) |
onValueChange | (value) => void | — | Fires when the selection changes |
inputValue / defaultInputValue | string | — | Controlled / uncontrolled text input value |
onInputValueChange | (value) => void | — | Fires as the user types (and on Escape-clear, with an escape-key reason) |
disabled | boolean | false | Disables all interaction |
Forwarding: aria-* land on the input (the element with the combobox role); data-*, ids and handlers land on the wrapper (Root) / dialog popup (Dialog). Both modes split identically.
Command.Dialog additional props
| Prop | Type | Default | Description |
|---|---|---|---|
open / defaultOpen | boolean | — | Controlled / uncontrolled open state |
onOpenChange | (open, event) => void | — | Fires when the dialog opens or closes |
dismissible | boolean | true | Whether Escape / backdrop click closes the dialog |
modal | boolean | true | Focus trap + scroll lock |
label | string | "Command palette" | a11y label for the Dialog popup |
mobilePresentation | "fullscreen" | "dialog" | "dialog" | Below md: keep the centered card, capped to clear the keyboard, or fill the viewport (safe-area aware, list flexes) |
initialFocus | Base UI initialFocus | input | Where focus lands on open — opt out where a virtual keyboard would occlude content |
Command.Input props
| Prop | Type | Default | Description |
|---|---|---|---|
placeholder | string | "Type a command or search…" | Placeholder text |
leadingIcon | ReactNode | search icon | Override the leading icon. Pass null to hide |
loading | boolean | false | Swap the leading icon for a spinner (150ms anti-flicker fade-in) |
shortcut | ReactNode | — | Trailing kbd hint |
trailing | ReactNode | — | Additional trailing content (mic button, Command.Close Cancel…) |
showClear | boolean | true | Show the × clear button when the input has a value |
With text in the field, Esc clears it and stops; with it already empty the event passes through, so a wrapping Dialog can dismiss — the APG two-stage contract.
Command.Toolbar
A padded, horizontally scrolling control row between the input and the results. Renders outside the listbox, so interactive children are legal — compose SegmentedControl, Select size="xs", Chip filters. No props beyond standard div attributes.
Give the control you compose the palette's own rounded value. The radius cascade crosses composition boundaries: a rounded="full" pill switcher inside a rounded="md" panel reads as a foreign part.
Command.Item props
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | inferred | Filter/selection identity. When omitted, the visible title text is used |
keywords | string[] | — | Extra terms the filter also matches (aliases) |
forceMount | boolean | false | Always visible regardless of the query — pinned rows |
leadingIcon / leadingAvatar | ReactNode | — | Leading slot (icon xor avatar) |
trailingIcon | ReactNode | — | Trailing slot — icon or status indicator |
hideLeading | boolean | false | Hide the leading slot entirely |
onSelect | (value) => void | — | Fires on click or Enter |
disabled | boolean | false | Disables the item |
Command.ItemMeta
Muted inline metadata at the trailing edge of a row — a suffix like "— Build", a domain, an author, a date. A sibling of the title column (like Command.Shortcut), not stacked under it. It joins the option's accessible name, so keep it short; never interactive.
Command.HighlightMatch props
| Prop | Type | Default | Description |
|---|---|---|---|
children | string | — | The text to render with query matches emphasised |
highlightClassName | string | semibold foreground | Classes for the matched segments |
Command.Group props
| Prop | Type | Default | Description |
|---|---|---|---|
heading | ReactNode | — | Group heading rendered above the items |
headingTrailing | ReactNode | — | Non-interactive slot at the end of the heading row — a count, a badge, a hint |
forceMount | boolean | false | Keep the group visible when every child filters out |
Put actions in an item, not in the heading row.
Command.Listis the combobox listbox, and ARIA givesrole="listbox"a closed set of permitted children:optionandgroup. A control in the heading row is therefore anaria-required-childrenfailure at critical severity, and no wrapper rescues it —role="presentation"is ignored on a focusable element, andaria-hiddenwould only trade the rule foraria-hidden-focus.Render a group action as a
Command.Item. That is a realoption, so it is an allowed child and it is reachable with the arrow keys — a button in the heading row is Tab-only, so reaching it means leaving the combobox. Passing a focusable node toheadingTrailingwarns in development.
<Command.Group heading="Recent searches" headingTrailing={<span>3</span>}>
<Command.Item value="darlene">…</Command.Item>
<Command.Item value="clear-recent" leadingIcon={<Eraser />} onSelect={clear}>
<Command.ItemTitle>Clear recent searches</Command.ItemTitle>
</Command.Item>
</Command.Group>Command.Crumbs props
| Prop | Type | Default | Description |
|---|---|---|---|
crumbs | ReactNode[] | — | Path segments. Last segment styled as the destination |
separator | ReactNode | chevron right | Custom separator (flips under RTL) |
Command.Empty props
| Prop | Type | Default | Description |
|---|---|---|---|
icon | ReactNode | — | Optional decorative icon above the text |
children | ReactNode | query echo | Default copy is «No results for "query"» (or "No results found" pre-query) |
Non-interactive (it lives in the listbox) — put recovery actions in a forceMount Command.Item, Command.Chips, or Command.Footer.
Command.Loading props
| Prop | Type | Default | Description |
|---|---|---|---|
label | ReactNode | "Loading…" | The announcement (portalled to the root's status region) and default visible message. null announces nothing |
variant | "spinner" | "skeleton" | "spinner" | Centered spinner row, or placeholder result rows |
rows | number | 3 | Skeleton row count |
children | ReactNode | — | Replaces the visible row only — label still announces; keep it non-interactive |
Command.Error props
| Prop | Type | Default | Description |
|---|---|---|---|
icon | ReactNode | — | Optional icon above the message |
label | ReactNode | "Something went wrong" | The announcement (same status-region portal as Loading); null silences |
children | ReactNode | label | Visible copy. Non-interactive — pair with a forceMount retry item |
Command.Chips / Command.ChipItem props
| Prop | Type | Default | Description |
|---|---|---|---|
label (Chips) | ReactNode | — | Optional label rendered before the pills |
action (Chips) | ReactNode | — | Trailing action — e.g. a "Clear all" ghost Button. Legal: the row is outside the listbox |
value (ChipItem) | string | — | Filter value |
keywords / forceMount (ChipItem) | — | — | Same filter model as Command.Item |
leadingIcon / leadingAvatar | ReactNode | — | Leading visual |
onRemove | () => void | — | Shows the × affordance; Delete/Backspace on the focused chip is the keyboard path |
The row is one Tab stop; Left/Right arrows move between chips.
Command.Body / Command.Sidebar props
| Prop | Type | Default | Description |
|---|---|---|---|
minHeight (Body) | string | number | "320px" | Min-height of the row layout |
value / defaultValue (Sidebar) | string | — | Controlled / uncontrolled active view |
onValueChange (Sidebar) | (value) => void | — | Fires when the active item changes |
value (SidebarItem) | string | — | Item identifier |
leadingIcon / trailingIcon (SidebarItem) | ReactNode | — | Icons |
The sidebar is a role="tablist" with the full keyboard contract: one Tab stop, Up/Down arrows move, Home/End jump, Enter/Space activates.
Command.Panel props
| Prop | Type | Default | Description |
|---|---|---|---|
maxHeight | string | number | "400px" | Maximum height of the scrollable panel |
The rich-content column: scrolls and pads like Command.List, carries no listbox role, so cards, chips and app icons are legal, Tab-reachable children. Use Command.List for options, Command.Panel for content.
Command.Section props
| Prop | Type | Default | Description |
|---|---|---|---|
heading | ReactNode | — | Heading rendered above the content |
headingTrailing | ReactNode | — | End-of-heading slot. Interactive is fine inside a Panel; inside a List the listbox rule applies (dev-warned) |
forceMount | boolean | false | Keep the section when every filterable child is hidden |
Auto-hides when filtering removes every Command.Item, Command.ChipItem, or Command.Card descendant.
Command.Grid / Command.Card props
| Prop | Type | Default | Description |
|---|---|---|---|
columns (Grid) | 1 | 2 | 3 | 4 | 5 | 6 | 3 | Number of columns. On touch it is a maximum: the grid drops a column rather than squeeze a card under the 44px target |
gap (Grid) | string | "gap-2" | Tailwind gap class |
value (Card) | string | — | Filter value |
keywords / forceMount (Card) | — | — | Same filter model as Command.Item |
title / description | ReactNode | — | Text under the media frame |
image (Card) | string | — | Image src (rendered as <img>) |
media (Card) | ReactNode | — | Custom content inside the aspect frame (overrides image) |
aspect (Card) | "square" | "video" | "3/4" | "4/3" | "1/1" | "3/4" | Aspect ratio of the media frame |
onSelect (Card) | (value) => void | — | Called when the card is activated |
Command.IconRow / Command.AppIcon props
| Prop | Type | Default | Description |
|---|---|---|---|
gap (IconRow) | string | "gap-3" | Tailwind gap class |
src (AppIcon) | string | — | Image src for the logo |
label (AppIcon) | string | — | Accessible label / tooltip |
children (AppIcon) | ReactNode | — | Custom content (overrides src) |
useCommandShortcut(key, onTrigger, options?)
Hook for binding a global keyboard trigger.
| Argument | Type | Description |
|---|---|---|
key | string | Case-insensitive key name (e.g. "k") |
onTrigger | () => void | Callback fired when the chord is pressed |
options.modifier | "auto" | "ctrl" | "meta" | "alt" | "shift" | "none" | "auto" (default) detects platform: ⌘ on macOS, Ctrl elsewhere |
options.enabled | boolean | Set false to suspend listening |
Unmodified and shift-only bindings are suppressed while typing in an input, textarea, select, or contenteditable; held-key auto-repeat and IME composition are ignored for every modifier.
Rich content layouts
For Mobbin / Linear / Raycast-style palettes, compose on top of the core API — with interactive rich content in Command.Panel, never Command.List (the listbox may own only options and groups):
Command.Chips+Command.ChipItem— horizontal pill row (recents, filters) with a legalactionslotCommand.Toolbar— scope tabs / filter dropdowns / mode togglesCommand.Body+Command.Sidebar+Command.SidebarItem— two-column layout with a real-tablist nav railCommand.Panel— the scrolling content column for anything interactiveCommand.Section— heading wrapper; auto-hides when its filterable children all filter outCommand.Grid+Command.Card— thumbnail card grid (filter-aware viavalue/keywords)Command.IconRow+Command.AppIcon— horizontal app logo strip
<Command.Dialog>
<Command.Input placeholder="Apps, Screens, UI Elements…" />
<Command.Chips action={<Button variant="ghost" size="xs">Clear all</Button>}>
<Command.ChipItem leadingIcon={<Search />} value="transactions">transactions</Command.ChipItem>
</Command.Chips>
<Command.Body>
<Command.Sidebar value={view} onValueChange={setView}>
<Command.SidebarItem value="trending" leadingIcon={<TrendingUp />}>Trending</Command.SidebarItem>
<Command.SidebarItem value="screens" leadingIcon={<Smartphone />}>Screens</Command.SidebarItem>
</Command.Sidebar>
<Command.Panel>
<Command.IconRow>
<Command.AppIcon src="/duolingo.png" label="Duolingo" />
</Command.IconRow>
<Command.Section heading="Screens">
<Command.Grid columns={3}>
<Command.Card value="signup" title="Signup" image="/signup.png" />
</Command.Grid>
</Command.Section>
</Command.Panel>
</Command.Body>
</Command.Dialog>Accessibility
- The input is the combobox:
aria-expanded="true",aria-controls→ the list,aria-autocomplete="list"(the APG trio for an always-open inline combobox). Arrow keys move the highlight viaaria-activedescendant, Enter selects, typing filters. Command.Listisrole="listbox"and owns only options and groups. Everything else the palette renders there — Empty, Error, the Loading row, separators — is presentational; interactive rich content goes inCommand.Panel.- Async announcements come from a root-owned, always-mounted
role="status"region outside the listbox:Command.Loading/Command.Errorportal their labels into it, and the palette announces "N results" 150ms after each query settles. - Esc clears a filled input first; a second Esc (field empty) reaches the Dialog and closes it.
- The dialog traps focus (
@base-ui/react/dialog) and stays a centered card belowmd, offset by the top safe area and capped so nothing hides behind a virtual keyboard;initialFocusopts out of input autofocus where a keyboard would occlude content. - Chips rows and the sidebar are single Tab stops with arrow-key roving; a chip with
onRemovedeletes with Delete/Backspace; the sidebar is a real vertical tablist. - Phone floors: input text ≥16px (no iOS zoom-on-focus), rows/chips/sidebar items/app icons ≥44px targets, the clear button's hit area is extended to 44px.