Components · Search and discovery
CommandPalette
A ⌘K / Ctrl+K palette: grouped commands, recent items, async search, nested pages and shortcut hints.
Commands, groups and shortcut hints
import { useState } from 'react';
import { Button, Kbd } from '@rookcom/monarch-react';
import { CommandPalette, formatHotkey, type CommandPaletteItem } from '@rookcom/monarch-react/data';
// BoardIcon, ListIcon, PencilIcon, PlusIcon, TrashIcon: demo icons from the docs site; use your own.
/** Built lazily: docs files cannot create JSX at module scope. */
const commands = (): CommandPaletteItem[] => [
{ id: 'new', label: 'Create task', group: 'Actions', icon: <PlusIcon />, shortcut: 'c' },
{ id: 'rename', label: 'Rename', group: 'Actions', icon: <PencilIcon />, shortcut: 'f2' },
{
id: 'archive',
label: 'Archive',
group: 'Actions',
icon: <TrashIcon />,
keywords: ['delete', 'remove'],
tone: 'danger',
},
{ id: 'board', label: 'Board', group: 'Go to', icon: <BoardIcon />, shortcut: ['G', 'B'] },
{ id: 'list', label: 'My tasks', group: 'Go to', icon: <ListIcon />, shortcut: ['G', 'M'] },
{
id: 'settings',
label: 'Settings',
group: 'Go to',
description: 'Workspace',
shortcut: 'mod+,',
},
];
function GroupsDemo() {
const [last, setLast] = useState<string | null>(null);
return (
<>
<CommandPalette
items={commands()}
trigger={
<Button variant="secondary">
Search commands
{formatHotkey('mod+k').map((k) => (
<Kbd key={k}>{k}</Kbd>
))}
</Button>
}
onItemSelect={(item) => setLast(item.label)}
/>
<span>{last ? `Ran: ${last}` : 'Press ⌘K or Ctrl+K anywhere on this page.'}</span>
</>
);
}
<GroupsDemo />Usage examples
Recent items and async search
import { useState } from 'react';
import { Button } from '@rookcom/monarch-react';
import { CommandPalette, type CommandPaletteItem, type CommandPaletteSource } from '@rookcom/monarch-react/data';
// BoardIcon, ListIcon, PencilIcon, PlusIcon, TrashIcon: demo icons from the docs site; use your own.
/** Built lazily: docs files cannot create JSX at module scope. */
const commands = (): CommandPaletteItem[] => [
{ id: 'new', label: 'Create task', group: 'Actions', icon: <PlusIcon />, shortcut: 'c' },
{ id: 'rename', label: 'Rename', group: 'Actions', icon: <PencilIcon />, shortcut: 'f2' },
{
id: 'archive',
label: 'Archive',
group: 'Actions',
icon: <TrashIcon />,
keywords: ['delete', 'remove'],
tone: 'danger',
},
{ id: 'board', label: 'Board', group: 'Go to', icon: <BoardIcon />, shortcut: ['G', 'B'] },
{ id: 'list', label: 'My tasks', group: 'Go to', icon: <ListIcon />, shortcut: ['G', 'M'] },
{
id: 'settings',
label: 'Settings',
group: 'Go to',
description: 'Workspace',
shortcut: 'mod+,',
},
];
const RECORDS = [
'Quarterly planning notes',
'Onboarding checklist',
'Release checklist',
'Pricing page copy',
'Support rota',
'Quarterly report draft',
];
/** A stand-in for an app's search API: filters sample titles after a short delay. */
const DOCUMENTS: CommandPaletteSource = {
id: 'documents',
group: 'Documents',
minQueryLength: 2,
search: (query, signal) =>
new Promise((resolve, reject) => {
const t = setTimeout(() => {
resolve(
RECORDS.filter((r) => r.toLowerCase().includes(query.toLowerCase())).map((r) => ({
id: r,
label: r,
icon: <ListIcon />,
})),
);
}, 350);
signal.addEventListener('abort', () => {
clearTimeout(t);
reject(new DOMException('Aborted', 'AbortError'));
});
}),
};
function RecentDemo() {
const [recent, setRecent] = useState<CommandPaletteItem[]>([
{ id: 'Support rota', label: 'Support rota', icon: <ListIcon /> },
]);
return (
<CommandPalette
hotkey={false}
items={commands()}
sources={[DOCUMENTS]}
recent={recent}
onItemSelect={(item, { page }) => {
if (page === null && item.group === undefined)
setRecent((r) => [item, ...r.filter((x) => x.id !== item.id)].slice(0, 5));
}}
trigger={<Button variant="secondary">Search documents</Button>}
/>
);
}
<RecentDemo />Nested page: Move to…
import { useState } from 'react';
import { Button } from '@rookcom/monarch-react';
import { CommandPalette } from '@rookcom/monarch-react/data';
// PencilIcon: demo icon from the docs site; use your own.
function PagesDemo() {
const [project, setProject] = useState('Website');
const projects = ['Website', 'Mobile app', 'Design system', 'Internal tools'];
return (
<>
<CommandPalette
hotkey={false}
items={[
{ id: 'move', label: 'Move to…', group: 'Task', page: 'move', shortcut: 'm' },
{ id: 'rename', label: 'Rename', group: 'Task', icon: <PencilIcon /> },
]}
pages={{
move: {
title: 'Move to',
placeholder: 'Choose a project…',
items: projects.map((p) => ({
id: p,
label: p,
disabled: p === project,
onSelect: () => setProject(p),
})),
},
}}
trigger={<Button variant="secondary">Task actions</Button>}
/>
<span>Project: {project}</span>
</>
);
}
<PagesDemo />App shortcuts with useHotkey
useHotkey('c', () => openCreateTask()); // ignored while typing
useHotkey(['mod+k', '/'], () => setPaletteOpen(true));
<Kbd>{formatHotkey('mod+shift+p').join('')}</Kbd> // ⌘⇧P or CtrlShiftPPurpose
Lets keyboard users jump anywhere, run any action or find any record from one search field, without leaving the keyboard. Import from @rookcom/monarch-react/data.
Use when
- An app-wide “go to / do / find” launcher opened with ⌘K or Ctrl+K.
- A quick switcher over records the app searches on the server, grouped by type.
- A short chain of choices from the keyboard, such as “Move to…” then a project (nested pages).
Avoid when
- Picking one value for a form field: use Combobox.
- Filtering the list on the page: use SearchInput or FilterBar.
- The only way to reach an action: every command must also exist in the visible interface.
Design principles
- The app owns the data. Commands, recent items and searches are passed in; Monarch owns keyboard, focus, filtering, debouncing, cancellation and announcements.
- One field, one list: the input keeps focus and the active result moves with
aria-activedescendant, so typing never stops. - Results are grouped by kind (“Actions”, “Go to”, “Documents”) with quiet headings; the active row is a muted fill, danger commands are red.
- Shortcut hints sit at the end of each row in Kbd, so the palette teaches the shortcuts it lists.
- Old results stay while new ones load, so the list does not flicker; a spinner replaces the search icon.
API reference
import { CommandPalette, defaultCommandFilter, formatHotkey, isTypingTarget, matchesHotkey, useHotkey } from '@rookcom/monarch-react/data';| Export | Kind | Rendering | Root class |
|---|---|---|---|
CommandPalette | component | client | .mn-command-palette- |
defaultCommandFilter | utility | client | – |
formatHotkey | utility | client | – |
isTypingTarget | utility | client | – |
matchesHotkey | utility | client | – |
useHotkey | hook | client | – |
Built on Monarch Dialog (Radix Dialog): focus is trapped, the page behind is inert, and focus returns to the trigger on close. Without a trigger, focus returns to where it was.
items, recent and page items are CommandPaletteItems: { id, label, description?, icon?, group?, keywords?, shortcut?, page?, disabled?, tone?, keepOpen?, onSelect? }.
sources are CommandPaletteSources: { id, group, search(query, signal), minQueryLength?, debounceMs? }. Each source is debounced and aborted on its own.
pages maps a key to { title, placeholder?, items?, sources? }; an item with page opens it.
useHotkey(hotkey, handler, { enabled, inFields }), formatHotkey(hotkey), matchesHotkey(event, hotkey) and isTypingTarget(target) are exported for the app’s own shortcuts and shortcut hints.
defaultCommandFilter(item, query) is the default ranking, for apps that wrap it.
App-wide shortcut tables: compose useHotkey; a registry is not part of Monarch yet.
Generated from the TypeScript declarations of packages/react/src/components/search/command-palette.
Properties
CommandPalette
A ⌘K / Ctrl+K command palette: a modal dialog with a search combobox and a grouped listbox of commands, recent items and results from asynchronous sources, nested pages such as "Move to…", and shortcut hints. The app supplies every command and search; the palette owns keyboard, focus, filtering, debouncing, cancellation and announcements.
| Prop | Type | Description |
|---|---|---|
className | string | Class on the dialog content. |
defaultOpen | boolean | |
emptyState | ReactNode | Shown instead of `messages.empty` when nothing matches. |
filter | ((item: CommandPaletteItem, query: string) => number) | Ranks an item for the query: 0 hides it, higher sorts first within its group. Default: every word of the query must appear in the label, description or keywords; label prefix matches rank highest. |
footer | ReactNode | Content at the bottom of the palette. Default: keyboard hints (↑↓ to navigate, ↵ to select, Esc to close). Pass `null` for none. |
hotkey | false | Hotkey | Opens and closes the palette from anywhere. Default `mod+k` (⌘K or Ctrl+K). Pass `false` to bind it yourself. |
hotkeyInFields | boolean | Whether a modified hotkey (⌘, Ctrl or Alt) also works while typing in another field. Default `true`. Single-key hotkeys such as `/` never fire while typing. |
items | readonly CommandPaletteItem[] | Commands shown on the first page. Filtered as the user types. |
messages | Partial<CommandPaletteMessages> | Override visible and announced strings. |
onItemSelect | ((item: CommandPaletteItem, context: { page: string | null; }) => void) | Called for every chosen item, after its own `onSelect`, with the page it was on. |
onOpenChange | ((open: boolean) => void) | |
onSearchChange | ((search: string) => void) | |
open | boolean | Controlled open state. |
pages | Readonly<Record<string, CommandPalettePage>> | Nested pages by key, opened by items with `page`. |
recent | readonly CommandPaletteItem[] | Shown in a "Recent" group while the query is empty, on the first page. The app keeps this list (for example in localStorage) from `onItemSelect`. |
search | string | Controlled query. |
sources | readonly CommandPaletteSource[] | Asynchronous result sources for the first page. |
trigger | ReactNode | An element (usually a button) that opens the palette. Rendered with Radix `asChild`, so it must forward its ref and props to a DOM element, as Monarch Button does. |
Variants
| Prop | Values | Description |
|---|---|---|
item.tone | 'danger' | A destructive command (data-tone="danger"): red text and a red-tinted active fill. |
item.page | 'string' | The item opens a nested page. A chevron marks it; the page title appears as a chip before the input. |
footer | 'default hints''ReactNode''null' | The bottom bar. Default: Kbd hints for ↑↓, ↵ and Esc. Hidden on phones when it only holds the default hints. |
States
| State | Exposed as | Behaviour |
|---|---|---|
| Active result | aria-selected="true", data-highlighted | The result Enter would run; muted fill (red tint for danger). |
| Disabled result | aria-disabled="true" | Shown in the disabled colour, skipped by the arrow keys, not clickable. |
| Loading | – | A source is searching: the search icon becomes a spinner and “Searching…” is announced. Previous results stay. |
| No results | – | The query matches nothing: messages.empty or emptyState is shown and announced. |
| Source error | – | A source rejected: “Could not load documents.” with a Retry button, in the danger colour. |
| Nested page | data-page="<key>" | The page title chip shows before the input; the input is renamed “Command palette: <page>”. |
Interaction behaviour
- The hotkey (default
mod+k: ⌘K on Apple platforms, Ctrl+K elsewhere) toggles the palette from anywhere. Pass an array for alternatives such as["mod+k", "/"], orfalseto bind it yourself. - Single-key hotkeys such as
/never fire while typing in a field or contenteditable; modified ones do unlesshotkeyInFields={false}. Keys during IME composition are ignored. - Every open starts on the first page with an empty query. With an empty query the
recentgroup comes first. - Typing filters
itemswith the default ranking (every word must match label, description or keywords; label prefixes first) or yourfilter. Groups keep the order their first item appears in; ungrouped items come first. - Each source waits
debounceMs(200) after the last keystroke, searches once the trimmed query isminQueryLength(1) long, and aborts its request when the query, page or open state changes. - Choosing an item runs its
onSelect, thenonItemSelect, then opens its page, or closes the palette unlesskeepOpen. - Pointer: moving over a row makes it active; clicking runs it. Rows never take focus.
Keyboard support
| Keys | Action |
|---|---|
| ⌘K / Ctrl+K (configurable) | Opens or closes the palette. |
| ArrowDown / ArrowUp | Moves the active result, skipping disabled ones and wrapping at the ends. |
| PageDown / PageUp | Moves the active result five rows, stopping at the ends. |
| Enter | Runs the active result, or opens its nested page. |
| Backspace (empty query) | Leaves the nested page. |
| Escape | Leaves the nested page; on the first page, closes the palette. |
| Tab | Moves between the input and any page chip or Retry button; focus stays inside the dialog. |
Home and End stay with the input (caret to start or end), as in the APG combobox pattern.
Accessibility
- Role
- dialog containing combobox + listbox (option, group)
- Accessible name
- The dialog and input are named by
messages.title(“Command palette”); the listbox bymessages.results; each group by its heading.
- APG combobox with listbox popup: the input has
aria-expanded="true",aria-controlsandaria-activedescendant; the active option hasaria-selected="true". - Result count, “No results” and “Searching…” are announced through a polite live region after results settle.
- Shortcut hints and icons are
aria-hidden, so an option’s name is its label and description. - The default footer is
aria-hidden: it repeats the keyboard model for sighted users only. - Verified with axe in jsdom (open, empty and nested-page states) and in Chromium, Firefox and WebKit on /components/command-palette.
Responsive behaviour
- Up to 40rem wide, near the top of the viewport, capped at 34rem tall; the result list scrolls.
- Below 640px it fills the screen (Dialog
fullScreenOnPhone), keeping clear of notches, and hides shortcut hints and the default footer. - Rows grow to
--mn-control-lgon coarse pointers so they are easy to tap.
Theme support
- Monarch semantic tokens only: the raised surface and border of Dialog,
--mn-color-bg-mutedfor the active row,--mn-color-danger-*for danger rows. - In forced-colours mode the active row gets a Highlight outline.
- The entrance animation is removed under
prefers-reduced-motion: reduce.
Do and don't
Do
- Put every command in the visible interface too; the palette is a shortcut, not the only path.
- Group results by kind and keep labels short verbs or nouns (“Create task”, “Settings”).
- Abort your request with the given
signalso stale responses never cost bandwidth. - Keep recent items yourself from
onItemSelect, guarding storage access with try/catch.
Don't
- Do not bind a single letter as the palette hotkey without checking it clashes with nothing; it already never fires while typing.
- Do not render more than one palette with the same hotkey on a page.
- Do not show shortcut hints for shortcuts the app does not actually bind.
- Do not put product data or API calls in Monarch: pass them in as items and sources.
Related components
- Combobox: Picks a value for a form field; the palette runs commands and navigates.
- Dialog: The modal the palette is built on.
- Kbd: Draws the shortcut hints and footer keys.
- SearchInput: Filters the list on the current page instead.
Dependencies
Testing coverage
- The hotkey opens it; dialog, combobox, listbox and named groups are wired (aria-controls, aria-activedescendant, aria-selected); axe in open, empty and nested-page states.
- Arrow keys skip disabled results and wrap; Enter runs
onSelectandonItemSelectand closes; PageUp/PageDown jump;keepOpenkeeps it open; pointer hover moves the active row. - Filtering by label, description and keywords with ranking; empty state announced.
- Recent group shows only with an empty query.
- Shortcut hints render as Kbd and stay out of the option name; default footer hints.
- Nested pages: placeholder, renamed input, back chip; Backspace and Escape leave the page before Escape closes; selections report their page.
- Async sources: minimum length, one debounced request per settled query, stale results kept while loading, the previous request aborted on new input and on close, error with Retry.
- Hotkey: single keys ignored in fields, modified keys work in fields unless
hotkeyInFields={false},hotkey={false}turns it off, it toggles closed. - Controlled
openandsearch, atrigger, and a fresh query on reopen. - Helpers:
formatHotkeyper platform,matchesHotkeywith symbols and Shift,isTypingTarget,defaultCommandFilterranking and accent folding. - Renders on the server.
| Check | Covered |
|---|---|
| Axe in jsdom | Yes |
| Server rendering | Yes |
| Browser suites | Visual baselines: /__gallery/command-palette, plus open-state shots at 1280 and 390; Cross-browser axe: /components/command-palette |
Tests: packages/react/src/components/search/command-palette