Skip to content

Command

An agent-native ⌘K command palette — a keyboard-first searchable list that navigates nested sub-command pages, ranks recents, and fires per-item shortcuts.

Stablev0.4.2added in v0.1.0@garn/ui/command
On this page

Default

Inline command list with grouped, filterable items and display-only shortcut hints.

Overview

Basic

Inline command list with grouped, filterable items and display-only shortcut hints.

Palette

The canonical ⌘K palette: a modal CommandDialog that binds its own open shortcut, floats recents up (useRecentCommands), groups sections, binds per-item shortcuts that fire, and shows a Kbd footer.

Async

States

Loading State

A static snapshot of the loading state — CommandLoading shows and CommandEmpty is suppressed (the loading↔empty guard).

Error State

A static snapshot of the async-failure state — CommandError (role=alert) with a Retry button.

Empty State

A static snapshot of the no-match state — a query that matches nothing renders 'No results for ‘…’'.

Actions

Actions

Per-item action panel — each row declares CommandItemActions; ↵ runs the primary, → / ⌘. opens the row's actions (each with a shortcut), Esc closes just the panel.

Recipes

Faceted Filter

A faceted multi-select filter recipe — Button + Popover + Command + Counter + Badge ('Status ⌄ 3 selected').

Installation

garn is copy-in — the garn CLI writes this component's source into your project, so you own and can edit it. It resolves any base files and installs the npm dependencies below for you.

terminal
npx garn-ui add command
First time? Set up garn in your project
Registry
command
Deps
lucide-react
Registry deps
utilscmdkdialogpopoverbuttonspinneruse-announceuse-controllable-stateuse-debounced-valueuse-event-listeneruse-local-storageuse-merged-refuse-prefers-reduced-motion

Import

import { Command, CommandDialog, CommandInput, CommandList, CommandEmpty, CommandLoading, CommandError, CommandGroup, CommandItem, CommandItemActions, CommandAction, CommandShortcut, CommandFooter, CommandSeparator } from "@garn/ui/command";

Anatomy

The parts this component is built from — compose them to assemble it.

CommandRequired[data-slot="command"]

Root — the filterable container (cmdk engine) plus the garn layer: a nested-page stack, controlled search, a loading flag (guards the loading↔empty race), per-item shortcut + item registries, the per-row action-panel state, and the imperative apiRef. Reflects data-loading / data-expanded / data-actions-open. Works inline or inside CommandDialog.

CommandDialog[data-slot="command-dialog"]

The canonical ⌘K palette host — drops a Command into a Dialog, owns controllable open state, binds the ⌘/Ctrl (+ optional /) open-shortcut via useCommandShortcut, and exposes a CommandDialogApi. Names the dialog for assistive tech via sr-only title/description (default "Command menu"). AppShellCommand is a thin pass-through over it.

CommandInputRequired[data-slot="command-input"]

The search box (role=combobox); its text filters the list. Context-backed by default (so pages can reset it and Backspace-on-empty pops), overridable with an explicit value for async search; on a nested page it swaps the leading search icon for the back-pill.

CommandListRequired[data-slot="command-list"]

The scrollable results region (role=listbox). Hides the native scrollbar for a scroll-aware edge-fade mask (data-overflow-start/-end), measures visible content to drive the root's data-expanded, and slides on page push/pop (reduced-motion aware).

CommandEmpty[data-slot="command-empty"]

The no-match state. Suppressed while the root is loading so 'no results' never renders during a fetch (guards cmdk #269) — distinguish 'no query yet' (show recents) from 'query, no match'.

CommandLoading[data-slot="command-loading"]

Async in-flight slot — a Spinner + label inside cmdk's Loading region. Auto-managed: renders only while the root is loading (still renders standalone, outside a Command).

CommandError[data-slot="command-error"]

Async-failure slot (not a cmdk concept) — role=alert (announced assertively), with an optional Retry Button via onRetry. Render it when your fetch rejects.

CommandGroup[data-slot="command-group"]

A labeled group of items (heading) — the seam for semantic sections (Recent · Suggestions · Actions · Navigation).

CommandItemRequired[data-slot="command-item"]

A selectable command (role=option), matched by its text/value. A shortcut renders a trailing hint AND fires the item's onSelect while the palette is focused (modifier-gated). Register a value so apiRef.run(value) can fire it.

CommandItemActions[data-slot="command-item-actions"]

Declares a row's secondary actions — place inside a CommandItem that has a value. Its CommandAction children render in the shared action panel that opens on → / ⌘.; it marks the row aria-haspopup, and renders only a small inline hint itself (aria-hidden). Pass CommandAction children directly (not wrapped in a fragment / .map).

CommandAction[data-slot="command-action"]

One secondary action — a CommandItem rendered inside the action panel (accepts a per-action shortcut); running it also closes the panel.

CommandShortcut[data-slot="command-shortcut"]

Right-aligned shortcut hint. Display-only on its own — CommandItem's shortcut prop is what actually binds the key.

CommandFooter[data-slot="command-footer"]

A footer hint bar below the list for keyboard discoverability — compose Kbd inside (e.g. '↵ select · → actions · esc close').

CommandSeparator[data-slot="command-separator"]

Divider between groups.

Props

The component's public props and their types.

apiRef
Description
Imperative handle — drive the palette programmatically (agent-native).
Type
React.Ref<CommandApi>
defaultSearch
Description
Initial search text for the uncontrolled input.
Type
string
Default
""
loading
Description
In-flight async results. Auto-shows `CommandLoading`, hides `CommandEmpty` (so they can't both render — cmdk #269), and turns filtering off.
Type
boolean
Default
false
onSearchChange
Description
Called with the new query on every search change (controlled or not).
Type
(value: string) => void
prefixes
Description
Leading-token → page routing (VS Code Quick Open): typing `>` at root jumps to a scope page and strips the token. Keyed by token, e.g. `{ ">": { page: "commands", label: "Commands" } }`.
Type
CommandPrefixMap
search
Description
Controlled search text. Uncontrolled by default via `defaultSearch`.
Type
string

Plus 289 inherited native HTML attributes.

CommandDialog

"data-slot"
Description
`data-slot` for the content surface (AppShellCommand overrides it).
Type
string
apiRef
Description
Imperative handle — open/close plus the inner Command's content ops.
Type
React.Ref<CommandDialogApi>
className
Description
Class for the inner `Command`.
Type
string
contentClassName
Description
Class for the `DialogContent` surface.
Type
string
description
Description
sr-only description for the dialog; pass `null` to supply your own `DialogDescription`.
Type
React.ReactNode
openOnSlash
Description
Also open on a bare `/` when focus isn't in a text field.
Type
boolean
shortcut
Description
Key for the ⌘/Ctrl open-shortcut; `null`/omitted disables.
Type
string
title
Description
sr-only accessible name for the dialog; pass `null` to supply your own `DialogTitle`.
Type
React.ReactNode

Plus 296 inherited native HTML attributes.

CommandError

onRetry
Description
Renders a Retry button when provided.
Type
() => void

Plus 280 inherited native HTML attributes.

CommandItem

shortcut
Description
A modifier-gated combo (`"mod+p"`, `"mod+shift+c"`) that both renders as a trailing shortcut hint AND fires this item's `onSelect` while the palette is focused. `mod` = ⌘ on mac, Ctrl elsewhere.
Type
string

Plus 285 inherited native HTML attributes.

CommandItemActions

hint
Description
Inline affordance on the row. Defaults to a subtle `⌘.` hint; pass `null` to hide it.
Type
React.ReactNode

Plus 280 inherited native HTML attributes.

Styling

Target these data-slots and states, and remap these tokens, to restyle without forking the component.

[data-slot="command"][data-slot="command-dialog"][data-slot="command-input-wrapper"][data-slot="command-back"][data-slot="command-action-panel"]

States

SelectorState
[data-selected=true]item-selected
[data-disabled=true]item-disabled
[data-loading]loading
[data-expanded]expanded
[data-actions-open]actions-open
[data-overflow-start]overflow-start
[data-overflow-end]overflow-end
[cmdk-empty]empty
PropertyTokenTier
inputHeight--garn-control-h-lgsemantic · air
itemMinHeight--garn-control-h-mdsemantic · air
scrollFade--garn-space-48semantic
pageMotionEasing--garn-ease-standardsemantic

When to use

Let users find and run an action (or jump to a thing) by typing — a ⌘K command palette, a searchable action menu, a quick switcher. Reach for the elevated surface when the palette must navigate sub-commands (nested pages / prefix routing), search an async source, rank recents, bind per-item shortcuts, or expose per-row secondary actions.

Reach for something else when

  • A short fixed set of actions (use dropdown-menu)
  • picking one value in a form (use select or combobox)
  • a long single-select that isn't action-oriented (use a searchable select pattern). It accelerates existing actions — it is not a replacement for visible navigation.

Overview

Command lets users find and run an action — or jump to a thing — by typing: a ⌘K palette, a searchable action menu, a quick switcher. It's cmdk's combobox+listbox engine wearing the garn layer, elevated to do four things a thin wrapper can't. It NAVIGATES: push nested sub-command pages (pick a project → pick an action) with a back-pill and Backspace-to-pop, or jump straight in with a leading-token prefix. It WAITS: CommandLoading / CommandError / a refined CommandEmpty are first-class async states, not a single 'no results'. It REMEMBERS: useRecentCommands ranks by recency + frequency and biases the filter so power users' commands float up. And it ACTS: an item's shortcut both shows AND fires, and CommandItemActions gives each row a secondary-action panel on → / ⌘.. CommandDialog is the one canonical host — it owns the open-shortcut, controllable open state, and an imperative apiRef (agent-native), and AppShellCommand is a thin pass-through over it. Four headless hooks carry the behavior: useCommandShortcut (bind the open key), useCommandPages (read/drive the stack), useRecentCommands (ranking + injectable store), useCommandSearch (debounced async with request cancellation).

Guidelines

Reach for the canonical host. Use CommandDialog with a shortcut (and maybe openOnSlash) for the global ⌘K palette; the bare Command is for an inline palette (a filter popover, a picker). Don't hand-roll a second ⌘K listener — that's exactly the duplication CommandDialog folds away.

Model async through the flags, not by hand. useCommandSearch owns the debounce, the loading/error/empty machine, and cancels stale requests; wire query/setQuery to CommandInput, loading to Command, and render CommandLoading / CommandError / CommandEmpty off the flags. Pass shouldFilter={false} when the server already returned the matches, so cmdk doesn't re-filter them.

Rank with recents. Call record(value) in an item's onSelect and pass useRecentCommands().filter to <Command filter={…}> to float recents up; render a 'Recent' CommandGroup from recent. The store is injectable (localStorage by default) so it can ride a consumer store.

Bind shortcuts, don't just show them. Put the combo on CommandItem's shortcut (it renders the hint AND fires the item) rather than a bare CommandShortcut (display-only). Every combo needs a non-shift modifier.

Keep it keyboard-driven and named. Type-to-filter, arrow to move, Enter to run, Esc to close; → / ⌘. for a row's action panel; Backspace-on-empty to go back. It's a combobox over a listbox — preserve that. Name the CommandDialog for its context via title/description.

Best practices

Do
  • Use CommandDialog with a shortcut for the global ⌘K palette (and openOnSlash for /); the bare Command for an inline palette.
  • Always include a CommandEmpty for the no-results state, and give non-text items a value so cmdk can match them.
  • Model async with useCommandSearch + loading + shouldFilter={false}, and render CommandLoading / CommandError / CommandEmpty off the flags.
  • Float recents up with useRecentCommands: record() in onSelect and pass its filter to Command.
  • Bind keys via CommandItem's shortcut (renders AND fires) — not a bare CommandShortcut.
  • Declare per-row secondary actions with CommandItemActions + CommandAction, and pass the CommandAction children directly (not wrapped in a fragment / .map).
  • Name CommandDialog via title/description, and add a CommandFooter of Kbd hints for discoverability.
  • Drive the palette from an agent / toolbar via apiRef (CommandApi / CommandDialogApi).
Don't
  • Don't hand-roll a second ⌘K listener — CommandDialog is the one host (AppShellCommand is a pass-through over it).
  • Don't rely on CommandShortcut to bind the key — it's display-only; use CommandItem's shortcut, and always include a modifier.
  • Don't leave cmdk filtering on for async results — pass shouldFilter={false} or the fetched matches get re-filtered.
  • Don't ship a palette with no empty state, and don't show 'no results' while loading (CommandEmpty is guarded, but only if you render it inside CommandList).
  • Don't wrap CommandAction children in a fragment or a .map()-array — the shortcut scan + panel render read them one level deep.

Content guidelines

  • Group commands under short headings and phrase each item as the action ('Create issue').
  • Write a clear placeholder and a helpful empty state ('No results for ‘…’'); distinguish 'no query yet' (show recents) from 'query, no match'.
  • Keep shortcut hints to the real binding, and keep the footer terse ('↵ select · → actions · esc close').

Troubleshooting

Async results get double-filtered — the server returned matches but the list still hides some as you type.

Cause. cmdk client-filters by the input text by default; on top of a server that already filtered, matches get dropped.

Fix. Pass shouldFilter={false} to Command for async search (useCommandSearch's recipe). loading also turns filtering off automatically while a fetch is in flight.

CommandLoading / CommandError never show, or 'No results' flashes during a fetch.

Cause. The async state isn't wired to the flags — CommandLoading renders only when the root is loading, and CommandEmpty is suppressed while loading (guarding cmdk #269), so both depend on the loading prop.

Fix. Set loading on Command (or use useCommandSearch's loading), and render CommandLoading / CommandError / CommandEmpty inside CommandList off the flags.

A CommandItem's shortcut hint shows but pressing the key does nothing.

Cause. A bare CommandShortcut is display-only, or the combo has no non-shift modifier (unmodified keys would collide with type-ahead, so they're not bound).

Fix. Put the combo on CommandItem's shortcut (it renders AND fires), and always include a modifier (mod+p, mod+shift+i). mod = ⌘ on mac, Ctrl elsewhere.

The per-row action panel doesn't open, or a row's actions are missing.

Cause. CommandItemActions must sit inside a CommandItem that has a value (the panel is keyed by value), and its CommandAction children are read one level deep — a fragment or a .map()-array hides them.

Fix. Give the row a value and pass the CommandAction children directly to CommandItemActions (flatten with Children.toArray if you must map). → / ⌘. then opens the panel.

Two ⌘K listeners fight, or the shortcut opens the wrong palette.

Cause. CommandDialog's shortcut defaults OFF (opt-in) precisely so an existing dialog with its own listener doesn't double-bind; adding a second host re-introduces the duplication CommandDialog folds away.

Fix. Use one CommandDialog with shortcut="k" as the host (AppShellCommand is a pass-through over it). Don't add a bespoke global keydown too.

The command palette needs a more specific accessible name than the default 'Command menu'.

Cause. CommandDialog ships an sr-only DialogTitle/DialogDescription so Radix's required name is always satisfied, but the defaults are generic.

Fix. Pass title (and optionally description) to name the palette for its context — both render sr-only; pass null to supply your own DialogTitle.

apiRef.setSearch / navigate / run seem to do nothing.

Cause. The inner Command is unmounted while a CommandDialog is closed, so its content ops no-op until it's open (open/close/toggle always work).

Fix. Open the dialog first (apiRef.open()), then drive content; for an always-mounted inline Command the CommandApi ops work immediately.

Accessibility

Role
combobox
ARIA APG
combobox
Focus
Focus stays in the input; ArrowUp/Down move the active option (aria-activedescendant). The back-pill and the per-row action panel both restore focus to the input on close/pop, so the tab stop never falls to <body>. Don't strip the focus ring.

Accessibility requirements

error

CommandDialog names the dialog via an sr-only title/description (default 'Command menu'); override `title`/`description` for context, or pass `null` and supply your own DialogTitle.

when always (CommandDialog)

warn

The combo renders as a trailing hint AND fires the item's onSelect while the palette is focused; it must carry a non-shift modifier (mod / ctrl / alt) so it never collides with type-ahead.

when a CommandItem sets `shortcut`

error

The back affordance is a button with an accessible name ('Go back — {label}'); popping it (or Backspace on an empty input) returns focus to the input and announces the navigation politely.

when a sub-command page is pushed

error

The row exposes aria-haspopup; → / ⌘. opens a focus-scoped panel whose Esc closes only the panel and restores focus to the input (never the outer dialog).

when a row renders CommandItemActions

warn

Result counts and page navigation announce via a polite live region and CommandError is role=alert; CommandEmpty is suppressed while `loading` so 'no results' never renders during a fetch.

when results filter or async state changes

Keyboard

A–ZType in the input to filter the list.
ArrowUpArrowDownMove between matching items.
EnterRun the highlighted item (its onSelect).
EscapeClose the palette (CommandDialog) — or, when a row's action panel is open, close just the panel.
BackspaceOn an empty input, pop the current sub-command page (or exit a prefix scope).
ArrowRightWhen the caret is at the end of the query, open the highlighted row's action panel (if it has actions).
Mod.Open the highlighted row's action panel.
ModKOpen / toggle the palette (CommandDialog with a `shortcut`).
/Open the palette when focus isn't in a text field (CommandDialog `openOnSlash`).
ModFire a highlighted item's bound `shortcut` (e.g. ⌘P) without leaving the input.
A1.3.1Info and RelationshipsA2.1.1KeyboardA2.4.3Focus OrderA4.1.2Name, Role, Value
  • CommandInput is role=combobox controlling the role=listbox CommandList; items are role=option with aria-selected. Filtering is by item text/`value` — give non-text items a `value` so they're searchable.
  • CommandDialog carries an sr-only DialogTitle/DialogDescription (default 'Command menu') so Radix always has an accessible name; focus is trapped and returns to the opener on close.
  • On a nested page the input grows a back-pill — a real button named 'Go back — {label}'; popping it (button or Backspace-on-empty) restores focus to the input, and page navigation is announced via a polite live region.
  • A row with secondary actions (CommandItemActions) gets aria-haspopup + aria-keyshortcuts; → / ⌘. opens a focus-scoped Popover panel whose Esc closes ONLY the panel (not the outer dialog) and returns focus to the input.
  • Filtered result counts are announced politely ('N results' / 'No results'); the async-failure slot (CommandError) is role=alert (assertive). CommandEmpty is suppressed while `loading`.
  • Page-push motion, the edge-fade, and hover affordances are decorative — the fade is offset by a per-row scroll-margin so keyboard navigation never parks a row under it, and motion is gated by prefers-reduced-motion.