Home/ Cookbook/ Shells & nav/ Command palette (⌘K)

Build a ⌘K command palette that's a power-user shortcut, not a nav replacement

By the end of this recipe you'll have a single ⌘K-triggered modal that fuzzy-matches across navigation, actions, recent items, and help docs — with keyboard-only navigation, grouped results, and a tiny 30-line matcher so you never bring in a fuzzy-search library.

Shells & nav 2 primitives · 0 blocks · 1 pattern ~30 min React · @corelithzw/react

Overview

A keyboard-first overlay: ⌘K opens, the user types, the matched results group by source (Pages, Actions, Recent, Help). Arrow keys move; Enter fires; Esc closes.

The command palette is the cheat-code for fast users. Once they learn that ⌘K + "new sale" + Enter beats clicking through three nav menus, they never go back. It's not a replacement for the sidebar — first-time users still need a real navigation tree to learn the app. The palette is the second-class entrance that everyone graduates to.

The command palette is for fast users — never the primary nav. If your hamburger menu is empty because everything moved into ⌘K, you've punished every new user to please a handful. Show keyboard hints next to each item so the palette teaches its own shortcuts.

The matcher in this recipe is a token-aware fuzzy match in 30 lines. No fuse.js, no react-fuzzy-search. The list is small (a few hundred items at most), and the JS is small enough that we don't owe the bundle a dependency.

Skip this recipe if your app has fewer than 10 navigable destinations. Five clicks across a five-page app is fine; ⌘K is overkill and just one more keyboard shortcut to teach.

What you'll build

Three states: open + empty query, open + typing, and the result-focused arrow-down state.

Huchu · ⌘K
Search or jump to…
Recent
Sale #2841
Suppliers
Suggested
Dashboard
New saleN
01 Empty — recents + suggested
Huchu · ⌘K
new s
Actions
New sale
New supplier
Pages
Sales report
Suppliers
02 Typing — grouped fuzzy matches
Huchu · ⌘K
help · refund
Help docs
How to refund a sale
Refunds & reversals
Split refunds

↑↓ navigate · ↵ select · Esc close

03 Help search lives here too

Required pieces

Everything this recipe pulls from @corelithzw/react.

@corelithzw/react exports used here: CommandPalette, useCommandPalette, Kbd, Stack. The useCommandPalette hook owns the open-state + global keybinding.

Step-by-step build

01

Model commands as data — one shape, many sources

Every entry in the palette — page, action, recent, doc — is the same record. That's how a single match function works across all of them, and how the renderer can group by kind without branching.

Code-only step

href = nav, run = action. Never both.

keywords let "reverse" find "Refund sale" without bloating the label.

Switch to the Code tab to see the snippet.

Step 1 · Command shape@corelithzw/react
02

Write the matcher inline — 30 lines, no dependency

A token-aware fuzzy match scores each candidate by (a) all query tokens appear, and (b) consecutive matches score higher. Bring in fuse.js when your dataset hits 10k items; for a command palette it's overkill.

Code-only step

Token AND filter, then bonus for prefix and subword-start.

"new s" → New sale (subword bonus), then Settings.

Switch to the Code tab to see the snippet.

Step 2 · Matcher
03

Wire the global keybinding once — useCommandPalette owns it

The hook installs the keydown listener at the window level, debounces auto-repeat, and ignores keystrokes inside text inputs (so ⌘K in a text field doesn't fight the OS). One mount per app — never call this hook twice.

Code-only step

⌘K wins even inside text inputs — convention beats local focus.

preventDefault stops the browser from intercepting in Safari.

Switch to the Code tab to see the snippet.

Step 3 · Global shortcut
04

Render the palette as one component — groups derived from kind

Grouping is a one-line groupBy. The selected index is local state in the palette — Arrow keys move it, Enter executes the command at that index. Recent items live at the top until the user types something; then ranked results take over.

new s
Actions
New sale
New supplier
Pages
Sales
↑↓ navigate · ↵ select · Esc close
Step 4 · Render groups
05

Persist recents so the palette gets smarter, not smug

Each successful select pushes the command id to a localStorage MRU list, capped at five. On open with no query, recents render at the top in MRU order — that's the only personalisation. We don't auto-rank by frequency; users hate when the palette quietly reorders itself.

Code-only step

No frequency ranking — surprising the user is worse than predictable.

Recents only show when the query is empty.

Switch to the Code tab to see the snippet.

Step 5 · Recents

Final composition

The whole AppPalette, assembled. Global shortcut + matcher + groups + recents + dynamic help-doc source.

Search or jump to…
Recent
Sale #2841
Suppliers
Actions
New saleN
↑↓ navigate·↵ select·Esc close
AppPalette.tsx@corelithzw/react

Variations

Three common forks. Each is a small diff from the final composition.

Async help-doc source

Help docs live in a separate index. Fetch them when the user types ? as a prefix and merge into the list.

const [help, setHelp] = useState<Command[]>([]);
useEffect(() => {
  if (!q.startsWith('?')) return;
  fetch('/api/help/search?q=' + q.slice(1))
    .then(r => r.json()).then(setHelp);
}, [q]);
const list = [...rank(q, all), ...help];

Scope to current page

The current page contributes its own commands (e.g. "Print this report"). Accept a scoped Command[] from context.

const scoped = useContext(PageCommands);
const all = [
  ...scoped,
  ...STATIC_COMMANDS,
  ...extraCommands,
];
// Scoped commands rank first when the query is empty

Single-letter shortcuts

Power users want N for "New sale" without ⌘K. Bind shown hints globally when no input has focus.

useEffect(() => {
  const onKey = (e: KeyboardEvent) => {
    const tag = (document.activeElement as HTMLElement)?.tagName;
    if (['INPUT','TEXTAREA'].includes(tag)) return;
    const hit = all.find(c => c.hint === e.key.toUpperCase());
    if (hit) { e.preventDefault(); select(hit); }
  };
  window.addEventListener('keydown', onKey);
}, []);

Accessibility

What this recipe takes care of for you.

  • The palette is a dialog. CommandPalette renders with role="dialog" + aria-modal="true" and traps focus until Esc closes it.
  • Results are a listbox. Each CommandPalette.Item is role="option"; the active item carries aria-selected="true" so screen readers announce highlight changes.
  • The query input announces results. The result count lives in an aria-live="polite" region above the list, so typing "new s" reads "4 matches, New sale highlighted".
  • Keyboard parity is complete. ↑↓ moves selection, Enter fires, Esc closes, Tab is intentionally not bound (the palette is a single focus surface, not a form).
  • Recents show the keyboard hint. Every action item renders a real <Kbd> with its shortcut. Screen-reader users hear "New sale, shortcut N".
  • Touch users get the same palette. The trigger button has aria-label="Open command palette" and lives in the top bar — mobile users can tap to open the same surface.