Design a command palette
A keyboard-first box, opened with Cmd+K or Ctrl+K, that finds and runs any action or item in the app. The hard parts are a shortcut that doesn't fight the browser, a registry that features add commands to without one giant import, fuzzy matching that ranks the right thing first, nested pages, server results that race the user's typing, and a combobox that works with a screen reader.
The question
Our project management app has grown to hundreds of actions spread across menus, settings pages, and right-click menus. Power users want one keyboard shortcut that opens a box where they can type a few letters and run anything: "assign to me", "go to settings", "change status to Done", or jump to issue ENG-412.
Design that command palette as a shared component. Walk me through how it opens, where its commands come from, how matching and ranking work, how it behaves with a keyboard and a screen reader, and how a feature team adds its own commands.
Real-life examples: VS Code's Command Palette (Ctrl+Shift+P or Cmd+Shift+P), Linear's Cmd+K menu, GitHub's command palette, Slack's Cmd+K quick switcher, Raycast on macOS, The cmdk and kbar React libraries
Requirements: what to ask before drawing anything
A command palette is a search box for actions. You press a shortcut, a box opens near the top of the screen, you type a few letters, and a list of matching commands appears. Enter runs the highlighted one.
Sublime Text brought the idea to code editors in 2011 and VS Code spread it. Now Linear, Slack, Vercel, and Notion ship one (GitHub offers one as a preview), usually on Cmd+K on a Mac and Ctrl+K elsewhere.
It looks like an autocomplete, and it shares a lot with one: a text box, a popup list, arrow keys, results that change as you type. The difference is what's in the list. An autocomplete suggests values for one field from one source. A palette mixes commands (things the app can do, like "Toggle dark mode") with entities (things the app has, like issues, projects, people), from several sources at once, and running a result does something instead of filling in a field.
So before designing, ask questions. Each answer below changes the design. The middle column is what this guide assumes.
| Question | Answer we assume | What it changes |
|---|---|---|
| Who is it for? | Power users on desktop. It's an extra path: every command also exists in a menu or a button somewhere. | The palette can be keyboard-first, but it can't be the only way to do anything. |
| How many commands? | About 400 static commands, plus 5 to 40 that depend on the current page or selection. | Local matching over a few hundred items is cheap. We still budget for 5,000 in case teams add more. |
| Does it search data too? | Yes: 80,000 issues, 300 projects, and 2,000 people per workspace, all on the server. | Server search with debounce and cancelling, merged with local results in one ranked list. |
| Nested steps? | Yes. "Change status" opens a second page listing the statuses. | A stack of pages, a breadcrumb, and Backspace to go back. |
| Who adds commands? | Twelve feature teams, each owning its own code. | A registry API that features call, with lazy loading so the palette doesn't import every feature. |
| Which devices? | Desktop browsers first. About 20% of sessions are on phones, which rarely have a keyboard. | A visible button to open it, and a full-screen layout on small screens. |
| Accessibility target? | WCAG 2.2 AA, following the WAI-ARIA Authoring Practices combobox pattern. | Roles, aria-activedescendant, announced result counts, and focus returned on close. |
Functional requirements
- Open with Cmd+K on macOS and Ctrl+K on Windows and Linux, and from a visible search button in the top bar.
- Search static commands, commands from the current page and selection, recent items, and server data (issues, projects, people) in one list with sections.
- Fuzzy matching:
chstshould find "Change status". Matched letters are highlighted. - Recent and frequent commands rank first when the input is empty or the match is close.
- Nested pages: "Change status ›" opens a list of statuses; Backspace on an empty input goes back.
- Run sync and async commands, show progress inside the palette, ask before destructive ones, and offer undo after.
- Show each command's own keyboard shortcut next to it, with labels that match the platform (⌘ on Mac, Ctrl elsewhere).
- Let any feature register and unregister commands without editing the palette's code.
Non-functional requirements
- Fast to open: the first frame within 100 ms of the shortcut, even before server data arrives. A palette that lags feels worse than a menu.
- Fast to filter: local results update within one frame (about 16 ms) per keystroke for up to 5,000 items.
- Server results at a p75 of about 300 ms after the user stops typing, never showing results for an older query.
- Small: the palette shell adds under 15 KB gzipped to the first load. Feature commands load when the palette opens.
- Accessible: fully usable with a keyboard and a screen reader; focus goes back where it was on close.
- Measurable: we know which searches end with nothing run, so product can add the missing commands.
Out of scope
- The search backend itself (indexing, typo tolerance on the server). We design the client and the API it calls.
- Letting users record their own custom shortcuts. We show shortcuts, we don't edit them.
- Natural-language commands handled by an AI model. It could plug in later as one more result source.
Check your understanding · Exercise 1
Which answers from the interviewer would change the design the most? Select all that apply.
What makes a command palette hard
A first version takes an afternoon: a dialog, a text box, an array of commands, filter with includes, and a map over the results. It demos well. It falls apart in real use for five reasons, and each one drives a later chapter.
- It lives everywhere. The shortcut must work on every page, even while the user is typing in a text field or an editor that also wants Cmd+K for "insert link". It competes with the browser and the operating system for keys.
- Its contents belong to other people. The palette team doesn't know what "Archive issue" does. Twelve teams own the commands, and the list changes with the page, the selection, and the user's permissions. That calls for a registry, not an array.
- Users type badly and expect magic. People type
stngfor "Settings",ghifor "Go to inbox", or the first letters of each word. Plain substring search misses all of these. Ranking decides whether the palette feels smart or useless: the right answer must be first, not fifth. - Results arrive at different times. Local commands are ready instantly; server issues arrive 100 to 800 ms later, sometimes out of order. The list must not jump under the user's finger, and an old reply must never overwrite a newer one.
- It's a keyboard and screen reader product first. Focus stays in the text box while the highlight moves through a list. Without the right ARIA, a screen reader user hears nothing when they press the down arrow.
There is also a hidden sixth: it's a component used by the whole app, so its API is a product too. A bad API means every team writes its own little palette, which is exactly what this design exists to stop.
Think first
Every command already lives in a menu somewhere. Why would a team spend weeks on a palette instead of improving its menus?
Menus make you remember where things are: which menu, which submenu, which settings tab. A palette makes you remember only what you want, in your own words. For an app with hundreds of actions, typing four letters beats three clicks through nested menus.
It also teaches. Showing each command's shortcut next to it, every time, turns palette users into shortcut users. And it gives product a free search log of what people want to do, including what they can't find.
Opening it: the shortcut, the button, and phones
The palette is only useful if it opens instantly, from anywhere, with a key the user already knows. That sounds like one line of code. It's actually three decisions: which key, how to listen for it without breaking text fields, and what users without a keyboard do.
Decision Which shortcut opens the palette?
What we pick: Use Cmd+K on macOS and Ctrl+K elsewhere as the main shortcut, because users already expect it there. Add Cmd+Shift+P / Ctrl+Shift+P as a second binding for developers who reach for it, knowing Firefox will never deliver it; test it in each other browser you support. Skip single-key shortcuts for opening, or make them opt-in.
preventDefault(). Test each binding in each browser before printing it next to a command.Listening for the shortcut without breaking typing
Listen on window for keydown, once, at the app root. Check metaKey on Mac and ctrlKey elsewhere, so Ctrl+K on a Mac (which deletes to the end of the line in macOS text fields, an old Emacs binding) still works for typing. Compare event.key in lowercase, since Shift or Caps Lock can make it "K". Skip events where event.isComposing is true: a Japanese or Chinese user in the middle of composing a character is not asking for the palette.
export function installPaletteShortcut(palette: { toggle(): void }) {
const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
const onKeyDown = (e: KeyboardEvent) => {
if (e.isComposing || e.repeat) return;
const mod = isMac ? e.metaKey : e.ctrlKey;
if (!mod || e.altKey || e.key.toLowerCase() !== "k") return;
// A rich text editor that uses Cmd+K for "insert link" opts out.
const target = e.target as HTMLElement | null;
if (target?.closest("[data-owns-mod-k]")) return;
e.preventDefault(); // stops the browser's address-bar search
palette.toggle(); // a second press closes it
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}
navigator is only read in the browser. Editors that own Cmd+K mark themselves so the palette leaves the key to them.Notice what it does not do: it doesn't skip all text fields. Users press Cmd+K while their cursor is in a comment box all the time, and they expect the palette. Only components that really use the key for something else opt out, with a data attribute, and those usually offer their own way in. GitHub, for example, documents Ctrl+Alt+K (Cmd+Option+K on Mac) as the way to open its palette while editing Markdown.
Single-key shortcuts are the opposite: they must be ignored in any input, textarea, or contenteditable element.
A visible button, and phones
A shortcut nobody knows about is a feature nobody uses. Put a search-style button in the top bar that reads "Search or run a command" with a small ⌘K or Ctrl K key hint on the right. It teaches the shortcut every time someone clicks it. Its accessible name should be the words, and the key hint can go in aria-keyshortcuts="Meta+K" (or Control+K), which tells assistive tech about the shortcut without reading glyphs aloud.
On phones there's usually no keyboard, so the button is the only way in. Below about 640 px wide, open the palette full screen with the input at the top, so the on-screen keyboard doesn't cover the results. Make each row at least 44 px tall for touch.
Hide the shortcut hints on phones: they mean nothing without a keyboard. An iPad with a hardware keyboard is the tricky case: show hints when a real key event has been seen, not based on screen size.
Check your understanding · Exercise 2
A user reports: "Cmd+K does nothing while I'm editing a comment." The comment box is a rich text editor. What are good fixes? Select all that apply.
What it searches: a command registry
The naive palette has one file, commands.ts, that imports every feature and lists every action. It works for a month. Then three things go wrong.
The palette's bundle grows with every feature, because importing archiveIssue pulls in the whole issues module. Every team edits the same file, so it becomes a merge-conflict hotspot owned by nobody. And the list can't see context: "Assign to me" only makes sense when an issue is open, and a static list doesn't know what's open.
So the palette shouldn't own commands. It should own a registry: a small in-memory store that features put commands into and take them out of. The palette only reads it.
Decision How do commands get into the palette?
What we pick: Pick the hybrid. Global commands live in small per-feature manifests that load when the palette first opens; their run code is a dynamic import() that loads on use. Context commands register at runtime from the mounted page or selection. The palette sees one merged list and doesn't know which way a command arrived.
type Command = {
id: string; // "issue.archive": stable, used for recents and telemetry
title: string; // "Archive issue"
section: string; // "Issue", "Navigation", "Settings"
keywords?: string[]; // ["delete", "remove", "hide"]: words users type instead
shortcut?: string[]; // ["Mod", "Shift", "A"]: shown, not bound, by the palette
icon?: string;
when?: (ctx: Context) => boolean; // hidden when false: no selection, no permission
danger?: boolean; // ask before running
children?: (ctx: Context, query: string) => Command[] | Promise<Command[]>;
run?: (ctx: Context) => void | Promise<void | Undo>;
};
type Undo = { label: string; undo: () => Promise<void> };
A few fields carry most of the design. id is stable forever, because recents, telemetry, and shortcuts key off it; titles get reworded and translated. keywords hold the words users actually type: people search "delete" for archive, "theme" for dark mode, "logout" for sign out.
when decides visibility from a context object (current route, selection, user permissions) so a command never appears where it can't run. A command has either run (a leaf that does something) or children (a page of more commands), never both.
when filter runs before matching, so a command that can't run is never scored or shown.Nested pages: "Change status ›"
Some commands need a second choice. "Change status" needs a status; "Assign to" needs a person. Showing all 6 statuses times every command as flat rows ("Change status to Done", "Change status to Todo") floods the list.
Instead, the parent shows a › arrow and opens a page: the palette pushes it onto a stack, shows a breadcrumb chip like ENG-412 › Status in the input, clears the text, and lists the children. The children function gets the query too, so "Assign to" can search 2,000 people on the server.
There's one useful exception: flatten the most common child results into the root list when the user types a specific query. Typing done at the root should still offer "Change status: Done", because the user clearly knows what they want. Many palettes do a version of this.
Recent and frequent commands first
When the input is empty, the palette shows suggestions: recent commands and items, then the most useful commands for this page. Store a small log per user in localStorage: for each command id, the timestamps of its last 10 runs, capped at 200 commands. Combine them into a frecency score, a mix of frequency and recency, so something used 30 times last month doesn't beat something used 3 times today forever.
const HALF_LIFE_DAYS = 7;
function frecency(uses: number[], now = Date.now()): number {
// uses: timestamps of the last 10 runs of this command
let score = 0;
for (const t of uses) {
const ageDays = (now - t) / 86_400_000;
score += Math.pow(0.5, ageDays / HALF_LIFE_DAYS);
}
return score; // 3 runs today ≈ 3.0; 10 runs 4 weeks ago ≈ 0.6
}
Frecency is also a tie-breaker while typing, not a trump card. If the user types arch and "Archive issue" matches well, a frequently used "Search archived projects" may move above it, but a weak match must never beat a strong one just because it's popular. The next chapter shows how the two numbers combine.
Check your understanding · Exercise 3
The billing team wants "Open billing settings" findable from any page, and "Cancel this subscription" only while a subscription is open. How should they register them? Select all that apply.
Matching and ranking: finding the right thing first
The naive filter is title.toLowerCase().includes(query). Type status and it finds "Change status". Type chst and it finds nothing, though that's exactly what a fast typist types.
Type set and it finds "Reset filters" and "Settings" in alphabetical order, so the one you wanted is second. Matching decides what's in the list; ranking decides what's first. Both matter, because most people press Enter on the first row without reading the rest.
Decision How should the query match a command?
What we pick: Use fuzzy subsequence matching with scores for local commands. Command titles are short and users type abbreviations, not long words with typos. Leave typo tolerance to the server's search engine for issues and people, where long names make it worth it.
How a match is scored
Our scorer gives each matched letter 1 point, then adjusts. A letter at a word start (the first letter, a letter after a space, dash, underscore, dot, slash, or colon, or a capital in camelCase like the S in toggleSidebar) gets +8, because people abbreviate by first letters. A letter right after the previous matched letter is consecutive and gets +5, because a run like stat is strong evidence.
Every skipped letter before a match costs 1, up to 3 per gap, so scattered matches sink. These weights are ours, tuned by hand; fzf and VS Code use different numbers for the same ideas.
The subtle part is that a query can match a title in several ways, and the first one found is often not the best. For lv against "Toggle list view", a greedy scan takes the first l it sees, the one inside "Toggle", and gets no word-start bonus for it. The l at the start of "list" is the better pick: it scores 8 more. So the scorer tries every position for each letter and keeps the best total, with memoization so it stays fast.
function score(q: string, lower: string, starts: Set<number>): Match | null {
const memo = new Map<number, Match | null>();
function best(qi: number, from: number): Match | null {
if (qi === q.length) return { score: 0, indices: [] };
const key = qi * 4096 + from;
if (memo.has(key)) return memo.get(key)!;
let top: Match | null = null;
for (let j = lower.indexOf(q[qi], from); j !== -1; j = lower.indexOf(q[qi], j + 1)) {
const rest = best(qi + 1, j + 1);
if (!rest) break; // a later j can't fit the rest either
let s = 1 + rest.score;
if (starts.has(j)) s += 8; // word start
if (qi > 0 && j === from) s += 5; // consecutive
s -= Math.min(3, j - from); // gap penalty
if (!top || s > top.score) top = { score: s, indices: [j, ...rest.indices] };
}
memo.set(key, top);
return top;
}
return best(0, 0);
}
starts is precomputed once per command, not per keystroke.Worked through for chst: "Change status" scores 27 (c and s are word starts, h and t are consecutive, one gap of 5 letters costs 3). "Copy hash to clipboard" scores 22: three word starts, but no runs and three gaps. "Archive project" doesn't match at all, since there's no s after the h. The abbreviation finds the right command first.
From match score to final order
- Keywords: score the query against each keyword too, and take the best. A keyword match scores 5 less than the same title match, so "Archive issue" (keyword "delete") ranks below a command actually titled "Delete".
- Frecency: add
min(6, 2 × frecency), rounded. That's enough to reorder close matches, never enough to lift a scattered match over a clean one. - Ties: shorter titles first (a match covering more of the title is tighter), then the registry's order.
- Sections: rank within each section, then order sections by their best item, so the best result in the whole palette is always the first row.
Try the rules in the lab below. Type a query and each candidate's score splits into its parts: 1 point per letter (base), word starts, runs, gaps, and the recency bonus. Switch to greedy alignment with lv to see the l inside "Toggle" cost 8 points, and turn the recency bonus off with to to see "Go to inbox" drop below the Toggle commands.
- Change status : 27
- Copy hash to clipboard : 22
2 results
Highlighting uses the indices the scorer returns: wrap each matched run in a mark element. Keep the row's text identical to the title so screen readers read "Change status", not letters split by markup. Never highlight inside the HTML string with innerHTML; build the pieces as elements, so a title with angle brackets can't inject markup.
Keeping it under one frame
At 5,000 commands with titles of about 25 letters, the scorer does roughly 5,000 small searches per keystroke. That fits in a few milliseconds on a mid-range laptop, if you avoid waste. Three habits keep it there.
- Precompute the lowercase title, lowercase keywords, and word-start positions when a command registers, never inside the keystroke loop.
- Narrow as the query grows. If the new query starts with the old one, anything that didn't match
chcan't matchchs. Score only the previous survivors. - Render less, not just match less. Drawing 5,000 rows costs far more than scoring them. Show the top 50 per section with a "Show all" row, or virtualize the list (see the virtualization lesson). Move matching into a Web Worker only past about 50,000 local items, since posting the results back costs time too.
Check your understanding · Exercise 4
With the scoring above, which statements are true? Select all that apply.
Server results while the user types
Commands are local, but issues, projects, and people live on the server: 80,000 issues won't fit in the browser. So for each query the palette shows local matches at once and asks the server for entities.
The naive version calls fetch on every keystroke. Typing deploy fires six requests. They come back in any order, and if the reply for dep lands after the reply for deploy, the list shows results for a query the user has already moved past.
The autocomplete guide covers search-as-you-type in depth, and the debounce-throttle and request-races lessons cover the two tools. Here we only need the palette's version, which has two twists: the server results must merge into a list that's already on screen, and the user may press Enter before they arrive.
Decision How do server results join the list?
What we pick: Combine the second and a small piece of the third: debounce plus abort for the server, with server results in their own sections, and a tiny local cache of recently viewed issues and all 300 projects so the most likely targets show instantly. Special-case exact keys: if the query looks like ENG-412, fetch that issue directly with no debounce and pin it as the first row.
AbortController, and the store also checks that a reply's query matches the input. Failure stays inside the Issues section; the commands above keep working.Keeping the list still
Results arriving late cause two problems. The first is the list jumping: if the user is reading row three when issues load above it, they press Enter on the wrong thing. Keeping server sections below local ones fixes most of it.
The second is the highlight: track the active item by id, not by index. When new rows arrive, look up the active id in the new list; if it's still there, keep it highlighted. If the active row was the first and nothing has been touched, stay on the new first row.
While a request is in flight, show one quiet row in the Issues section, "Searching issues…", rather than a spinner over the whole palette. Local commands are already usable, so the palette must never look blocked.
If the request fails, replace that row with "Couldn't load issues. Retry" as a normal option the user can arrow to and press Enter on. The commands above keep working.
let controller: AbortController | null = null;
async function searchServer(query: string, signal: AbortSignal) {
const url = `/api/palette/search?q=${encodeURIComponent(query)}&limit=8`;
const res = await fetch(url, { signal });
if (!res.ok) throw new Error(`search ${res.status}`);
return (await res.json()) as SearchResponse;
}
const onQuery = debounce((query: string) => {
controller?.abort();
if (query.length < 2) return store.setServer({ status: "idle" });
controller = new AbortController();
store.setServer({ status: "loading", query });
searchServer(query, controller.signal)
.then((data) => store.setServer({ status: "done", query, data }))
.catch((err) => {
if (err.name !== "AbortError") store.setServer({ status: "error", query });
});
}, 150);
Cache replies by query for about 60 seconds. Backspacing from deploy to dep should show the old results instantly, not refetch. The cache lives only as long as the page: entity titles change, and a stale issue title for a minute is fine, for an hour is not.
Check your understanding · Exercise 5
The user types ENG-412 quickly and presses Enter 80 ms later, before any server reply. What should happen? Select all that apply.
The interaction: a combobox that never loses focus
A palette is used almost entirely from the keyboard: type, arrow, Enter. The naive build moves real focus onto each row as you press the down arrow, the way a menu does. That breaks typing: the moment focus leaves the text box, the next letter goes nowhere.
The other naive build keeps focus in the box but highlights rows with a CSS class only. It looks right, and a screen reader says nothing at all when you press the down arrow.
Decision Where does keyboard focus live while the user moves through results?
What we pick: Use the combobox pattern with aria-activedescendant. A palette is a text box first; focus belongs in it. This also matches the WAI-ARIA Authoring Practices combobox pattern and our dropdown and autocomplete components, so screen reader users meet the same behavior everywhere.
<dialog aria-label="Command palette">
<input role="combobox" aria-expanded="true" aria-controls="cp-list"
aria-autocomplete="list" aria-activedescendant="cp-opt-issue.assign-me"
aria-label="Search or run a command" />
<div id="cp-list" role="listbox" aria-label="Results">
<div role="group" aria-labelledby="cp-sec-issue">
<div id="cp-sec-issue" aria-hidden="true">Issue</div>
<div id="cp-opt-issue.assign-me" role="option" aria-selected="true">Assign to me</div>
<div id="cp-opt-issue.status" role="option" aria-selected="false">Change status ›</div>
</div>
</div>
<div role="status" class="sr-only">12 results</div>
</dialog>
Two details are easy to get wrong. Option ids come from the command id, not the row index, so the active id stays valid when results reorder. And the section heading is not an option: it labels the group through aria-labelledby, so a screen reader can say "Issue, group" as you enter it without the heading becoming a stop for the arrow keys.
The keys, one by one
| Key | What it does | Why |
|---|---|---|
| Down / Up | Move the highlight to the next or previous option, skipping headings; wrap from last to first. | Wrapping helps in a short list: Up from the first row reaches the last one at once. |
| Enter | Run the highlighted command, or open its page if it has children. | One key for "do it". Cmd+Enter or Ctrl+Enter can mean "open in a new tab" for entities. |
| Tab, or Right at the end of the text | Open the highlighted command's page. | Lets users go deeper without running anything. Right only counts when the caret is at the end, so it still moves the caret normally. |
| Backspace on an empty input | Go back one page. | Feels like deleting the breadcrumb chip. With text in the box, it deletes text as usual. |
| Escape | Close the palette and return focus. | Users press Escape to get out. Some apps make it go back a page first; we use Backspace for that so Escape always means leave. |
| Home / End | Move the caret in the text, as usual. | The input is editable, so we leave text-editing keys alone. |
The mouse joins in without fighting the keyboard. Moving the pointer over a row highlights it, but listen to pointermove, not pointerenter. Otherwise, when arrow keys scroll the list under a pointer that's sitting still, the row under it grabs the highlight back. After each highlight change, call scrollIntoView({ block: "nearest" }) on the active row so it never hides below the fold.
The lab below is a working mini palette with the same key table. Open it from the button, go into "Change status" with Tab or Enter, come back with Backspace on an empty input, and watch aria-activedescendant change while focus stays on the input. Close it with Escape or by running a command, and focus returns to the button.
Check your understanding · Exercise 6
A screen reader user presses the down arrow in the palette and hears nothing. Which bugs could cause this? Select all that apply.
Running a command: progress, confirmation, focus, and undo
Pressing Enter is where the palette hands off to a feature, and it's where most palettes get sloppy. The naive version calls command.run() and closes. That's fine for "Toggle dark mode". It's wrong for "Archive issue", which takes 400 ms on the server and can fail, and for "Delete project", which should never run from one stray Enter.
Decision What does the palette do while an async command runs?
What we pick: Stay open with a pending row, but only for up to about 1 second. If the command hasn't finished by then, close the palette and let a toast show progress and the final result. Most commands finish well under that, so users see an inline result; slow ones don't trap them.
Confirming destructive commands
Commands marked danger don't run on the first Enter. The palette pushes a confirm page in place: "Delete project Atlas? This removes 312 issues." with two options, Cancel highlighted first and Delete second. The user arrows down and presses Enter again.
Doing it inside the palette, rather than opening a second modal dialog on top, keeps one focus trap and one Escape behavior. Prefer undo over confirm when the action can be undone: archiving is reversible, so it runs at once and offers undo; deleting a project isn't, so it asks.
Closing and giving focus back
When the palette closes without navigating, focus should go back where it was. A dialog opened with showModal() does this itself in current browsers, but the palette still saves document.activeElement on open so it can check the result, as the modal-dialog guide does.
Check isConnected first: the command may have removed that element, like the row of an issue you just archived. If it's gone, focus the nearest sensible thing, such as the next row in the list or the page's main heading, never body. When the command navigates, the router owns focus and moves it to the new page's heading.
undo it got back to the toast. On failure nothing closes, so the user keeps their place.Undo works best as a returned value. A command's run can resolve with { label, undo }; the palette passes it to the toast system, which shows "Issue archived" with an Undo button for about 8 seconds and binds Cmd+Z or Ctrl+Z while the toast is visible, unless focus is in a text field, where that key must keep undoing typing. The palette doesn't know how to undo anything. It only knows that the command offered.
Check your understanding · Exercise 7
The user runs "Archive issue" on the issue page, and the archived issue's page closes and returns to the list. Where should focus go? Select all that apply.
Rendering: modal dialog, shortcut labels, and small screens
The palette floats over the whole app. The easy way is a div with position: fixed and a big z-index. It works until it doesn't: an ancestor with transform or filter becomes the containing block of a fixed element, so it can be clipped or placed wrong; a toast or tooltip with a higher z-index sits on top; and focus can Tab into the page behind it.
Decision How should the palette be rendered?
What we pick: Use the modal dialog. A palette is a short, focused task: you open it, do one thing, and leave. The top layer removes every stacking bug, and showModal() makes the page inert for free. It also exposes the dialog as modal to assistive tech, so aria-modal="true" is optional; give it an accessible name such as "Command palette".
Place it near the top of the screen, around 15% down, not centered. Centered dialogs move up and down as the result count changes. A fixed top edge keeps the input still, which matters when the user's eyes are on it. Cap the list height at about 60% of the viewport and let it scroll.
Showing shortcuts the user's way
Commands store shortcuts with an abstract Mod key, like ["Mod", "Shift", "A"]. At render time, Mod becomes ⌘ on Mac and Ctrl elsewhere; Shift becomes ⇧ on Mac and Shift elsewhere; Alt becomes ⌥ on Mac. Mac users expect symbols with no plus signs, in Apple's fixed modifier order of Control, Option, Shift, Command (⌃⌥⇧⌘), so this one reads ⇧⌘A. Windows users expect words joined by plus signs (Ctrl+Shift+A).
Wrap each key in a kbd element, and give the group a visually hidden text like "Shift Command A", since screen readers read symbols like ⌘ and ⇧ inconsistently or skip them.
Detect the platform once with navigator.userAgentData?.platform where it exists (Chromium browsers), falling back to navigator.platform or the user agent. Do it on the client only: a server-rendered label guessed wrong flashes on hydration, so render the shortcut column after mount, or on the server from the Sec-CH-UA-Platform request header that Chromium browsers send by default.
Check your understanding · Exercise 8
Why is showModal() a better fit than a fixed-position div for the palette? Select all that apply.
Architecture: who owns what
With the decisions made, the pieces fall out. The rule behind every boundary is the same: the palette owns finding and the keyboard, features own meaning. The palette never imports a feature, and a feature never touches the palette's DOM.
| Part | Responsibility | Why the boundary is here |
|---|---|---|
| Shortcut listener | One keydown listener at the app root that opens and closes the palette. | Lives outside the palette so it works before the palette's code has loaded. |
| Palette shell | The trigger button and the lazy-loaded dialog. | Keeps the first-load cost tiny; the heavy parts load on first open or on idle. |
| Command registry | A store of commands from manifests and from mounted components, plus the current context. | Features write, the palette reads. Twelve teams, no shared file. |
| Matcher | Prepares commands once and scores them per keystroke. | Pure functions, easy to test and to move into a worker later. |
| Server search client | Debounce, abort, a 60-second query cache, and exact-key lookups. | All network timing in one place, shared with the global search page. |
| Result merger | Combines local, recent, and server results into ordered sections. | The only place that knows ranking rules across sources. |
| Palette view | Combobox, listbox, page stack, breadcrumb, running and confirm states. | Renders from state; holds no business logic. |
| History store | Frecency log in localStorage; telemetry events out. | Survives reloads; shared with the empty-state suggestions. |
Loading order matters for the 100 ms open budget. On first page load, ship only the listener and the trigger button. After the page is idle (requestIdleCallback, or a short timeout where it's missing), prefetch the palette view, the matcher, and the feature manifests: together about 30 KB gzipped in our budget.
If the user presses Cmd+K before that finishes, open the dialog shell at once with the input focused, buffer what they type, and fill in results when the code arrives. A user typing ar in the first 50 ms must not lose those letters.
Follow one keystroke through it. The input updates the query in the palette store. The matcher scores the registry's commands, filtered by when against the context, and hands back the top matches with indices. In parallel, the search client debounces and fetches entities.
The merger builds sections, keeps the active id stable, and the view renders. Enter sends the active command to its run, which may lazy-load the feature module, call the API, and return an undo for the toast.
Data model and the component API
The palette's state is small but has a clear shape. Most of it is UI state that lives only while the palette is open; the registry and history outlive it.
type Page = { commandId: string; title: string; query: string; activeId: string | null };
type PaletteState = {
open: boolean;
returnFocusTo: HTMLElement | null;
stack: Page[]; // root page first; breadcrumb = stack.slice(1)
server: { status: "idle" | "loading" | "done" | "error"; query?: string; data?: SearchResponse };
running: { commandId: string; startedAt: number } | null;
confirming: string | null; // id of a danger command awaiting Enter
};
type SearchResponse = {
query: string;
sections: { kind: "issue" | "project" | "person"; total: number; items: Entity[] }[];
};
type Entity = { id: string; key?: string; title: string; subtitle?: string; url: string; score: number };
type HistoryEntry = { id: string; uses: number[] }; // last 10 timestamps, in localStorage
Each page keeps its own query and active id, so going back restores what you had typed at that level. The server response carries its query back, which lets the store drop any reply whose query no longer matches the current one: a second guard behind AbortController, for replies that were already parsed when the abort happened.
The search endpoint
GET /api/palette/search?q=deploy&types=issue,project,person&limit=8
Accept: application/json
200 OK
Cache-Control: private, max-age=30
{
"query": "deploy",
"sections": [
{ "kind": "issue", "total": 41, "items": [
{ "id": "iss_8812", "key": "ENG-412", "title": "Deploy preview fails on forks",
"subtitle": "In progress · Priya", "url": "/eng/issue/ENG-412", "score": 0.92 } ] },
{ "kind": "project", "total": 2, "items": [] }
]
}
One endpoint instead of three keeps it to one request per query. total lets the section show "See all 41 issues", which jumps to the full search page with the query. Errors use the app's standard error body; a 429 means the client pauses server search for a few seconds while local results keep working. Server scores are on a different scale from local fuzzy scores, so the merger never compares them directly: it orders within each section and places sections by fixed rules plus the exact-key exception.
The API for feature teams
// features/billing/commands.ts: loaded when the palette first opens
export const billingCommands = defineCommands([
{ id: "billing.open", title: "Open billing settings", section: "Settings",
keywords: ["invoice", "plan", "payment"],
run: async ({ router }) => router.push("/settings/billing") },
]);
// features/issues/IssuePage.tsx: present only while this page is mounted
useCommands(() => [
{ id: "issue.archive", title: "Archive issue", section: "Issue",
shortcut: ["Mod", "Shift", "A"],
run: async () => (await import("./archive")).archive(issue.id) },
], [issue.id]);
// app root
<CommandPalette manifests={[billingCommands, issueCommands]} searchTypes={["issue", "project", "person"]} />
Check your understanding · Exercise 9
Which pieces of state should survive closing and reopening the palette? Select all that apply.
Accessibility, performance, and telemetry
The combobox chapter covered the core of accessibility. A few more pieces separate a palette that passes an audit from one that blind and low-vision users actually prefer to the menus.
Announcing what changed
When results change, sighted users see the list shrink. Screen reader users hear nothing unless we tell them. Keep a visually hidden role="status" region inside the dialog (it's polite by default) and write "12 results" into it about 500 ms after typing stops, so it doesn't talk over every keystroke.
Write "No results for chstx" when the list is empty, "Searching issues" only if the server takes over a second, and "Change status, 6 options" when a page opens. Don't announce the active row: aria-activedescendant already does that.
- Groups: each section is
role="group"witharia-labelledbypointing at its heading. Support varies: some screen readers announce the group name when you enter it, others don't. Putting the section name in each option's description (aria-describedby) is a fallback some teams add. - Shortcut text: the visible ⇧⌘A glyphs are for eyes; the option's description carries "Shortcut: Shift Command A" in words.
- Reduced motion: skip the scale-in animation when
prefers-reduced-motion: reduceis set. A palette opens dozens of times a day. - Contrast: the highlighted row's indicator needs at least 3:1 contrast against the rows around it (WCAG 1.4.11, non-text contrast), and it shouldn't rely on hue alone (1.4.1, use of color). A left bar or a border does both.
- Testing: test with VoiceOver and Safari, NVDA and Firefox or Chrome, and JAWS and Chrome. Combobox support differs across these pairs, and automated checkers can't hear what's announced.
Performance budget
| Moment | Budget | How we meet it |
|---|---|---|
| First page load | Under 15 KB gzipped for listener and trigger | Everything else is a dynamic import() prefetched on idle. |
| Shortcut to first frame | Under 100 ms | Dialog shell renders before matching; input is focused in the same frame. |
| Keystroke to updated list | Under 16 ms for 5,000 items | Precomputed keys, narrowing, top 50 per section rendered. |
| Stop typing to server results | About 300 ms at p75 | 150 ms debounce, small limit, 60-second query cache. |
| Enter to visible result | Under 100 ms for sync commands | Pending row at once; hand off to a toast after 1 second. |
Watch Interaction to Next Paint (INP), the Core Web Vital for responsiveness: a good score is 200 ms or less at p75. A palette that re-renders 5,000 rows on every keystroke is a classic way to fail it. Measure keystroke-to-paint in the field with the Event Timing API data that INP is built on, tagged with the result count.
Telemetry: learning what users can't find
The palette is the only place users tell you in words what they want to do. Log three events (see the analytics-tracking lesson for batching and consent): palette_open with how it opened (shortcut or button), palette_run with the command id, the query length, and the result's rank, and palette_abandon when it closes without running anything, with the last query. Abandoned queries with zero results are a to-do list for product: if 300 people a week type export csv and find nothing, that command should exist.
Rank tells you about ranking quality. If the median rank of run commands is 1, ranking works. If users often press the down arrow three times before Enter for the same query, a boost or a keyword is missing. A weekly report of "query, chosen command, rank" is enough to tune weights by hand.
Check your understanding · Exercise 10
Which telemetry and announcement choices are sound? Select all that apply.
Running it in the interview
In 45 minutes you can't cover all of this, and you shouldn't try. Interviewers want to see you find the hard parts yourself and go deep on two or three. A plan that works:
- Minutes 0 to 5: scope. Ask about the number of commands, server data, nested steps, who adds commands, and devices. Say the numbers out loud: 400 commands, 80,000 issues, 12 teams.
- Minutes 5 to 10: draw the wireframe and the architecture. Input, sections, rows with shortcuts, breadcrumb. Then registry, matcher, search client, merger, view.
- Minutes 10 to 20: the registry and its API. The
Commandtype, manifests plus the hook,when, lazyrun. This is the senior signal for a component question. - Minutes 20 to 30: matching and ranking. Subsequence matching, word-start and consecutive bonuses, a worked example, frecency as a tie-breaker, server results in their own sections with debounce and abort.
- Minutes 30 to 40: keyboard and accessibility. Combobox with
aria-activedescendant, the key table, Backspace to go back, focus return on close, the status region. - Minutes 40 to 45: running commands and telemetry. Pending states, confirm vs undo, zero-result logging.
If you're short on time, drop telemetry and rendering details; never drop the registry or the keyboard model. Expect these follow-ups, and have a short answer ready for each:
- "How would you add AI commands?" As one more async source with its own section and a longer debounce. It returns commands from the registry by id, never free-form actions, so permissions and confirmations still apply.
- "How do you stop a team from registering 500 commands?" A lint rule and a registry check in development: required fields, unique ids, a cap per manifest, and a review of titles against a style guide.
- "The palette is slow on a big workspace. Where do you look?" Profile a keystroke: is it scoring, merging, or rendering? Usually rendering. Cap rows, memoize rows by id, then consider a worker.
- "How do you test it?" Unit tests for the scorer with a table of query, title, expected order. Component tests for the key table. End-to-end tests for open, search, run, and focus return. Manual screen reader passes before release.
Think first
The interviewer asks: "Why not just use cmdk and move on?" What's a strong answer?
Using a library is often right, and saying so is a good sign. cmdk handles the combobox markup, filtering, and groups, and its docs show how to build nested pages; kbar adds a registration hook. Either can be the view layer of this design.
But the library doesn't decide the hard parts: the registry API for twelve teams, lazy manifests, when conditions, server search with abort and exact-key lookups, frecency, confirm and undo, focus return after navigation, and telemetry. A strong answer picks the library for rendering and keyboard handling, and spends the interview on those decisions.
Summary
- A palette owns finding and the keyboard; features own meaning. Commands arrive through a registry, never a central import list.
- Use static manifests with lazy
runcode for global commands, and a mount-time hook for commands that depend on the page or selection. Awhencondition hides what can't run. - Open with Cmd+K or Ctrl+K and cancel the browser default, keep a visible button for discoverability and phones, and let editors that own the key opt out.
- Rank with fuzzy subsequence scores (word starts, consecutive runs, gap penalties), pick the best alignment, and use frecency only as a capped tie-breaker.
- Server results get their own sections, a 150 ms debounce,
AbortController, and a query cache. Exact keys like ENG-412 skip the debounce and pin to the top. - Focus stays in the input: a combobox with a listbox and
aria-activedescendant, ids from command ids, and a polite status region for counts. - Run async commands with a pending row, confirm only what can't be undone, hand slow work to a toast with undo, and always return focus somewhere real.
- Log abandoned zero-result queries (scrubbed) and the rank of chosen commands: that's how the palette learns what's missing.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- WAI-ARIA Authoring Practices: Combobox pattern Roles, states, and keyboard behavior for an input with a popup list.
- MDN: aria-activedescendant How focus can stay in one element while another is announced as active.
- MDN: The dialog element showModal, the top layer, and the cancel and close events.
- MDN: AbortController Cancelling in-flight fetch requests when the query changes.
- MDN: aria-keyshortcuts Telling assistive tech about a keyboard shortcut.
- WCAG 2.2: Understanding Character Key Shortcuts (2.1.4) Why single-key shortcuts must be possible to turn off or remap.
- web.dev: Interaction to Next Paint (INP) The responsiveness metric a slow palette hurts.
- cmdk on GitHub A widely used React command menu component.
- kbar on GitHub A React command bar with actions, nested parents, and a useRegisterActions hook.
- GitHub Docs: GitHub Command Palette Default shortcuts, and a second shortcut for use while editing Markdown.
- Apple Human Interface Guidelines: Keyboards Modifier key symbols and the order to list them in.
- fzf on GitHub A fuzzy finder whose scoring rewards word boundaries and consecutive matches.
Concepts behind it
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
- Debounce and throttleTwo small timing tools that turn a flood of events (keystrokes, scrolls, resizes) into a few calls you can afford: debounce waits for a pause, throttle runs at a steady rate.
- Request races and cancellationResponses can come back in any order, so a page can show the answer to an old question; learn to let only the latest answer reach the screen and to cancel the work nobody needs.
- List virtualizationRendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.
- Product analyticsRecording what users do as well-named events that follow a tracking plan, delivered reliably even offline, with consent respected, and checked so that the numbers people decide with are true.