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.
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.
↑↓ navigate · ↵ select · Esc close
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
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.
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.
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.
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.
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.
⌘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.
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.
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.
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.
Final composition
The whole AppPalette, assembled. Global shortcut + matcher + groups + recents + dynamic help-doc source.
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.
CommandPaletterenders withrole="dialog"+aria-modal="true"and traps focus until Esc closes it. - Results are a listbox. Each
CommandPalette.Itemisrole="option"; the active item carriesaria-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.