Cmd+K command palettes have become standard UX for power-user-friendly apps — Linear, Vercel, GitHub, Slack, Raycast all have them. The interview tests whether you understand the combobox pattern, fuzzy search, keyboard interactions, and the ergonomics that distinguish a great command palette from a mediocre one.
Functional requirements
- Cmd+K (or Ctrl+K) opens the palette
- Type to filter commands
- Arrow keys to navigate; Enter to execute
- Fuzzy search (typos and partial matches)
- Recent commands surface at top
- Keyboard shortcuts shown next to commands
- Categories (Navigation, Actions, Help)
Architecture
Three pieces:
- Command registry: all commands the app supports. Key each command by a stable id rather than its label, so recents and analytics survive a rename, and let features register their commands as they mount instead of hard-coding one giant list.
- Palette UI: input + results list (modal). This is a combobox — the input keeps DOM focus the whole time while an adjacent listbox shows filtered options, and you move a virtual highlight rather than moving real focus into the list.
- Keyboard handler: global Cmd+K trigger. Attach one listener at the document level and call preventDefault so the keystroke doesn’t also trigger a browser or OS shortcut; a common follow-up is how you avoid stacking duplicate listeners across re-renders.
Library: cmdk
The de facto React command palette library by Vercel. Used by Linear, Vercel, Raycast, Sourcegraph. Headless — bring your own styles.
For interview answers, knowing it exists and using it as the model is appropriate. For learning, building from scratch is instructive.
The command registry
type Command = {
id: string;
label: string;
description?: string;
icon?: ReactNode;
shortcut?: string[]; // ['cmd', 'k']
category: string;
action: () => void;
keywords?: string[]; // for search
};
Commands registered globally (or per-context). Filter by current page when palette opens.
Fuzzy search
Use a library:
- Fuse.js: classic, configurable. Gives you weighted multi-field matching and a tunable threshold, but the extra scoring work per query shows up once your list runs into the thousands — reach for it when you need weighted matching more than raw speed.
- uFuzzy: faster, modern. Built for latency on large lists and returns match ranges you can feed straight into highlighting, at the cost of fewer scoring knobs than Fuse.
- command-score: small, tailored for command palettes. It scores one string against a query and rewards prefix and in-order substring hits, which is how people actually type command names; it does one job, so you pair it with your own sort and grouping.
cmdk uses command-score. Tuned for the “user types prefix or substring” pattern.
Keyboard handling
- Cmd+K: open palette (also Ctrl+K on Windows/Linux)
- Escape: close
- Arrow Up/Down: navigate
- Enter: execute
- Cmd+1-9: jump to nth result (some apps)
Search-as-you-type
Filter on every keystroke. With command-score, even 1000 commands filter in under 5ms.
Highlight matching characters in results for visual confirmation.
Recent commands
Track last 5–10 used. Show at top when palette opens with no input. Boosts power users dramatically.
Persist to localStorage; sync across devices via your backend if useful.
Sub-palettes
Some palettes have nested levels:
- Type “/” → switch to slash commands
- Select “Move issue” → next level shows projects
Visual: breadcrumb at top of palette (“Move issue ›”).
Loading async commands
Some results require API calls (search across all docs):
- Show loading spinner: keep the already-loaded local commands visible while remote results stream in, so the palette never looks empty mid-search.
- Debounce 200ms: wait for a pause in typing before firing the request, so a fast typist sends one call instead of one per keystroke.
- Cancel previous request: abort the in-flight fetch (AbortController) when a new keystroke arrives, or a slow earlier response can land after a newer one and overwrite the right results.
- Cache recent results: memoize responses by query string so backtracking or retyping a term is instant and doesn’t hit the network again.
Mobile
Mobile keyboards lack Cmd. Use:
- Search button in top bar: give touch users a visible tap target, since there’s no keyboard shortcut to discover on a phone.
- Long-press on a button to reveal palette: a secondary gesture for power users, but keep the visible entry point too — long-press is undiscoverable on its own.
- Slide-up sheet instead of centered modal: anchor the input near the bottom so it sits above the on-screen keyboard and stays in thumb reach.
Accessibility
- Modal pattern: focus trap, Escape closes, return focus. Trap Tab inside the palette while it’s open and send focus back to the element that triggered it on close, so keyboard users aren’t dumped at the top of the page.
- aria-activedescendant for highlighted option: the input keeps DOM focus and points at the id of the active row, which is how a screen reader announces the highlighted option as you arrow through without moving real focus.
- aria-expanded on input: toggle it true/false so assistive tech knows the results list is open, and pair it with role=combobox and aria-controls pointing at the listbox.
- aria-live for “X results found”: announce the result count in a polite live region after filtering, since a sighted user watches the list shrink but a screen-reader user needs it spoken.
Common antipatterns
- Doesn’t open quickly (palette should appear in <100ms)
- Search blocks main thread on every keystroke
- No keyboard shortcuts beyond arrows and Enter
- Confused state when many results match
- Recent commands not surfaced
Frequently Asked Questions
Should I use cmdk or build from scratch?
cmdk for production. Building from scratch is good interview practice and instructive.
How do I prevent palette from showing in textareas?
Check event.target — if it is an input/textarea, only open if the user explicitly typed Cmd+K (not just K).
Can the palette do more than navigate?
Yes — Linear and Notion let you create issues, assign people, change status, all from the palette. Treat it as a command surface.
Useful next steps:
