Command palette recipes
Three copy-paste CommandPalette configurations — the landing actions palette, the docs static-index search, and the volksdroid async catalog jump.
Overview
The CommandPalette is one surface with three consumers. Each recipe below is
the whole configuration — copy it, swap the data, and wire the open state to
your own trigger or useShortcut. The palette shares the Dialog
overlay contract (portal, focus trap, Escape/backdrop close) and the fuzzy
matcher; what changes per consumer is the source and the shortcut.
import { CommandPalette, useShortcut } from "@cronwerk/ui";
| Consumer | Source | Shortcut | Notes |
|---|---|---|---|
| Landing actions | static items, grouped | ⌘⇧P | small curated action set |
| Docs search | static index (searchText + recency) | ⌘K | recency seeds the empty query |
| Volksdroid catalog | async source | ⌘K | debounced + virtualized (2,000+) |
Opening with a shortcut
Every recipe pairs the controlled open state with useShortcut. The default
is mod+k (⌘K on macOS, Ctrl+K elsewhere); the landing uses mod+shift+p so it
never collides with the browser’s find. Shortcuts are suppressed while an
editable field has focus, and a duplicate binding warns in development.
const [open, setOpen] = useState(false);
useShortcut("mod+shift+p", () => setOpen(true)); // ⌘⇧P / Ctrl+Shift+P Recipe 1 — Landing actions palette
A small, curated command set grouped by intent. Static items are fuzzy-matched
and ranked client-side; groups fixes the section order.
Landing actions source
const groups = [
{ id: "start", label: "Get started" },
{ id: "product", label: "Product" },
{ id: "company", label: "Company" },
];
const actions = [
{ id: "start-free", label: "Start free", group: "start", hint: "↵" },
{ id: "pricing", label: "See pricing", group: "start", keywords: ["plans"] },
{ id: "workflows", label: "Workflows", group: "product" },
// …
];
function Palette() {
const [open, setOpen] = useState(false);
useShortcut("mod+shift+p", () => setOpen(true));
return (
<CommandPalette
open={open}
onOpenChange={setOpen}
items={actions}
groups={groups}
ariaLabel="Site actions"
placeholder="Jump to anything…"
/>
);
} Recipe 2 — Docs search (static index)
The docs site feeds a pre-built static index into the palette. searchText
folds the section and keywords into what the matcher sees, and recency biases
recently-opened pages to the top of an empty query.
Docs static-index search source
const index = pages.map((page) => ({
id: page.id,
label: page.title,
description: page.path,
searchText: `${page.title} ${page.section} ${page.keywords.join(" ")}`,
recency: page.lastOpened,
onSelect: () => router.push(page.path),
}));
<CommandPalette
open={open}
onOpenChange={setOpen}
items={index}
ariaLabel="Search documentation"
placeholder="Search the docs…"
/>; Recipe 3 — Volksdroid catalog jump (async)
The marketplace has thousands of listings, so the palette streams from an async
source and virtualizes the list. The source is debounced and cancellable —
return a promise that honors the AbortSignal, and the palette handles the
loading, error, and empty states.
Async catalog source source
const source: CommandSource = (query, signal) =>
fetch(`/api/catalog?q=${encodeURIComponent(query)}`, { signal })
.then((r) => r.json());
<CommandPalette
open={open}
onOpenChange={setOpen}
source={source}
debounceMs={180}
itemHeight={52}
ariaLabel="Catalog jump"
loadingState="Searching the catalog…"
placeholder="Search 2,000 droids…"
/>; Usage
Do
Keep one palette instance per app, open it from a trigger and a shortcut, and feed it whichever source fits — static for curated actions, async for large or remote sets.
Don't
Don’t bind two palettes to the same shortcut, and don’t pass a giant static
items array when the data is remote — reach for a source so queries are
debounced and cancelled.
Accessibility
The palette is an APG combobox with a listbox popup: focus stays in the input
while aria-activedescendant moves a virtual cursor over the options. Arrow
keys move the active row, Home/End jump to the ends, Enter selects, and Escape
closes (restoring focus to the trigger via the Dialog focus trap). Group
headings are exposed through role="group" + aria-labelledby; the empty state
is announced without stealing the cursor.