cf.completefrontend
My progress
SYSTEM DESIGN · #24

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.

QuestionAnswer we assumeWhat 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.
A wireframe of the command palette. At the top is the combobox input with the query arch. Under an Issue section heading, Archive issue is the active option with the shortcut Shift Command A next to it, then Search archived. Under an Issues section from the server, ENG-388 Archive old builds, and a row saying Searching issues. A footer shows the keys: arrows move, Enter runs, Escape closes. Notes on the right name each part.Cmd+K palettearchIssueArchive issue⇧⌘ASearch archivedIssues (server)ENG-388 Archive old buildsSearching issues…↑↓ move ↵ run esc closecombobox inputfocus stays heresection = groupactive optionserver sectionloads latershortcut hintsand key legend
Focus never leaves the input; the highlight moves through the options. Local commands show at once, and the server section fills in below them so nothing above it moves.

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: chst should 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.

  1. 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.
  2. 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.
  3. Users type badly and expect magic. People type stng for "Settings", ghi for "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.
  4. 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.
  5. 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?

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?

Cmd+K / Ctrl+K

The convention on the web: Linear, GitHub, Slack, Vercel, Notion, and documentation sites built on Algolia DocSearch use it.

Call preventDefault() on the keydown so the browser's own action doesn't run.

Pros

  • Users already know it, so they try it without being told.
  • One hand, easy to reach.

Cons

  • Chrome and Firefox on Windows and Linux use Ctrl+K to search from the address bar (Firefox on Mac uses Cmd+K), so you must cancel the browser default. Neither browser reserves it, so cancelling works.
  • Rich text editors use it for "insert link".

Cmd+Shift+P / Ctrl+Shift+P

VS Code's binding, familiar to developers.

Pros

  • Rarely used by apps, so few conflicts inside the page.

Cons

  • In Firefox, Ctrl+Shift+P (Cmd+Shift+P on Mac) opens a private window. Firefox marks that key as reserved, so a page can't cancel it, and Firefox users lose the shortcut entirely.
  • Three keys, two hands for many people.

A single key like / or ?

GitHub and YouTube use / to focus search. Many apps use ? for a shortcuts help sheet.

Pros

  • Fastest possible to press.

Cons

  • Must be ignored whenever the user is typing, or nobody can type a slash.
  • Single-key shortcuts must be possible to turn off or remap to pass WCAG 2.1.4 Character Key Shortcuts.

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.

The path of a key press: operating system, then browser, then the page's keydown listener, then the palette. Cmd+Space on macOS is taken by the OS for Spotlight. Ctrl+T or Ctrl+W are taken by the browser and the page can't stop them. Ctrl+K or Cmd+K pass the OS; the browser's default is cancelable, the page calls preventDefault, and the palette opens. Ctrl+Shift+P in Firefox opens a private window and the page can't stop it.OSBrowserPage listenerPaletteCmd+Space on macOSSpotlightneverneverneverCtrl+T or Ctrl+Wpassesnew tabcan't stopneverCtrl+K or Cmd+KpassescancelablepreventedopensCtrl+Shift+P in Firefoxpassesnew windowcan't stopnever
The page only gets keys the OS and browser let through, and only some of those can be cancelled with 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.

ts
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);
}
One global listener, installed from an effect at the app root so 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?

One central list

A single array in the palette package imports each feature's actions.

Pros

  • Simple to read and to test.
  • Easy to see every command in one place.

Cons

  • Bundle grows with every feature.
  • No context: can't add or remove commands as pages and selections change.
  • One file, twelve teams.

Runtime registration by mounted components

A hook like useCommands([...]) adds commands when a component mounts and removes them when it unmounts.

This is how kbar's useRegisterActions works.

Pros

  • Context comes for free: the issue page registers issue commands only while it's on screen.
  • Each team owns its commands next to its code.

Cons

  • A command only exists while its component is mounted, so "Open billing settings" can't be found from the inbox page.
  • Order of registration can make the list shift.

Static manifests with lazy actions, plus runtime registration

Each feature ships a tiny manifest: id, title, keywords, section, shortcut, and a load function that dynamically imports the real code.

Pages and selections still register context commands with the hook.

Pros

  • Global commands are searchable everywhere for a few bytes each.
  • Heavy feature code loads only when a command runs.
  • Context commands still come and go with the page.

Cons

  • Two ways to register, so the docs must say which to use when.
  • Titles in the manifest can drift from the feature's real behavior unless tests cover them.

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.

ts
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> };
The shape every command has, whoever registers it.

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.

Where results come from. Feature manifests with global commands and lazy run code, and the useCommands hook with page and selection commands, both feed the Registry, which filters by when conditions. The Registry feeds the Matcher. The History store with frecency also feeds the Matcher. The Matcher sends its top matches to the Merger, and the Search API sends entities later. The Merger builds sections with stable ids and hands one ranked list to the palette view.Manifestsglobal, lazy runuseCommandspage + selectionHistoryfrecency, localSearch APIissues, peopleRegistryfilter by whenMatcherfuzzy scoresMergersections, stable idsPalette viewone ranked listtop Nentities, later
Two ways in for commands, one way out for results. The 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.

ts
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
}
Each past use counts for less as it ages: half as much every 7 days.

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?

Substring or prefix match

Keep items whose title contains the query, or whose words start with it.

Pros

  • Trivial, fast, and predictable.

Cons

  • Misses abbreviations like chst or gts.
  • No sense of a better or worse match, so ranking falls back to alphabetical.

Fuzzy subsequence with scoring

Match if the query's letters appear in order in the title, gaps allowed.

Score each match: bonus for word starts and camelCase boundaries, bonus for consecutive letters, penalty for gaps. fzf, VS Code's fuzzyScore, and the command-score algorithm that cmdk bundles all build on these ideas.

Pros

  • Handles abbreviations and initials.
  • Gives a real number to rank by.
  • Matched positions come out for free, for highlighting.

Cons

  • Needs care to pick the best alignment, not just the first.
  • No typo tolerance: stauts won't match "status".

Typo-tolerant search (edit distance)

Count how many single-letter edits turn the query into part of the title, as Fuse.js does with the Bitap algorithm.

Pros

  • Forgives typos.

Cons

  • Short queries match almost everything: with one edit allowed, go matches most words with a g or an o.
  • Slower and harder to explain why an item ranked where it did.

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.

The query chst scored against three titles. Change status matches c, h, s, t at positions 0, 1, 7, 8: c is a word start for 9 points, h is consecutive for 6, a gap of 5 letters costs 3, s is a word start for 6 after the gap, and t is consecutive for 6, total 27. Copy hash to clipboard matches c at 0 for 9, h at 5 for 6, s at 7 for 0, and t at 10 for 7, total 22. Archive project has no s after the h, so it does not match."Change status": 27Change status+9+6+6+6gap of 5: -3"Copy hash to clipboard": 22Copy hash to clipboard+9+60+7"Archive project": no s after the h, no match
Word starts and runs earn the points; gaps cost them. The abbreviation lands on two word starts and two runs in Change status, so it wins by 5.
ts
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);
}
Best alignment by recursion with a memo. 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.

Alignment
  1. Change status : 27
  2. Copy hash to clipboard : 22

2 results

Score breakdown for the query chst with best alignment and the recency bonus. Change status: base 4, starts 16, runs 10, gaps -3, recent 0, total 27. Copy hash to clipboard: base 4, starts 24, runs 0, gaps -6, recent 0, total 22basestartsrunsgapsrecenttotalChange status41610-3027Copy hash to clipboard4240-6022
2 of 12 commands match. Try lv with greedy and then best: greedy takes the l inside Toggle and loses 8 points. Try to with and without the recency bonus.

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 ch can't match chs. 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?

Fetch every keystroke, show whatever arrives

One request per input event.

Pros

  • Freshest possible results.

Cons

  • Six requests for one word, most wasted.
  • Out-of-order replies show stale results.
  • The list jumps under the cursor several times a second.

Debounce, abort, and a separate section

Wait about 150 ms after the last keystroke, then fetch.

Abort the previous request with AbortController when a new one starts.

Server results fill their own sections (Issues, Projects, People) below the commands.

Pros

  • One or two requests per word.
  • Stale replies can't land.
  • Commands never move when server results arrive.

Cons

  • Adds 150 ms before server results start loading.
  • An exact issue key match sits low unless we special-case it.

Ship a local index of entities

Download the user's recent 500 issues and all projects into IndexedDB and match them locally, like commands.

Pros

  • Instant results for the most likely targets.
  • Works offline.

Cons

  • Stale data and a sync problem.
  • Can't cover 80,000 issues, so you still need the server.

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.

A sequence diagram between the User, the Palette, the Search client, and the API. The user types dep; after a 150 millisecond pause the palette sends query dep to the search client, which sends GET q=dep. The user keeps typing to deploy; the palette sends query deploy; the search client aborts the dep request and sends GET q=deploy. The API replies 200 with 8 issues, and the search client gives the palette results for deploy. If the API replies 500 instead, the search client tells the palette, which shows an error row with Retry.UserPaletteSearch clientAPItypes depdep, after 150 msGET ?q=deptypes deployquery deployabort depGET ?q=deploy200: 8 issuesresults for deployor 500error row, Retry
Only the newest query can land: the old request is aborted with 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.

ts
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);
One controller per palette: each new query aborts the last one.

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?

Move real focus to each row (roving tabindex)

Each row gets tabindex="-1" and the arrow keys call focus() on it, like the menu pattern.

Pros

  • Screen readers announce the focused row with no extra ARIA.

Cons

  • Typing stops: you have to send focus back to the input on every letter.
  • The caret and the selection in the input are lost and restored over and over.

Focus stays in the input, aria-activedescendant points at the row

The input is a combobox; the list is a listbox of option rows.

The input's aria-activedescendant holds the id of the highlighted row; assistive tech treats that row as focused.

Pros

  • Typing and moving through the list never fight.
  • The same pattern as our autocomplete and select guides, so one keyboard engine serves all three.

Cons

  • Every row needs a stable, unique id.
  • The app must scroll the active row into view itself, since the browser isn't moving focus.

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.

html
<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>
The list is always shown while the palette is open, so aria-expanded stays true. The dialog is opened with showModal().

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.

How aria-activedescendant works. The input with role combobox keeps DOM focus. Its aria-activedescendant attribute points at the option Assign to me inside the listbox, under the Issue group. Other options are Change status, Go to inbox, and Open settings, under a Navigation group. The screen reader reads the input, sees the active descendant, and announces Assign to me.input role=comboboxDOM focus stays hereScreen readersays "Assign to me"announceslistboxIssue (group)Assign to meChange status ›Navigation (group)Go to inboxOpen settingsaria-activedescendant
The arrow keys change one attribute on the input, not where focus is. The browser exposes the referenced option as the active item, so typing and moving never fight.

The keys, one by one

KeyWhat it doesWhy
Down / UpMove 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.
EnterRun 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 textOpen 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 inputGo back one page.Feels like deleting the breadcrumb chip. With text in the box, it deletes text as usual.
EscapeClose 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 / EndMove 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.

The palette is closed. Focus is on the page. Last key: none.palette closedaria-activedescendant= (none)focus is onpagelast keynone
Focus the button and press Ctrl+K or Cmd+K, or click it.

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?

Close at once, run in the background

Close the palette, call run(), and report the result with a toast.

Pros

  • Feels instant.
  • The palette is free for the next command.

Cons

  • If it fails, the user's context is gone and they must reopen and retype.
  • Commands that open a page of choices can't work this way.

Stay open with a pending row, close on success

Mark the row as running, block other runs, keep the input readable.

Close when run() resolves; on failure show the error inline.

Pros

  • Failures appear right where the user is looking, with a Retry.
  • Nothing runs twice from a double Enter.

Cons

  • A slow command holds the palette open.
  • Needs a time limit before handing off.

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.

The palette as a state machine. Closed goes to Browsing on Cmd+K or the button. Browsing loops on itself with Tab and Backspace as pages are pushed and popped. Enter on a danger command goes to Confirming; Enter on a normal command goes to Running. Enter again in Confirming goes to Running. Running goes to Error if the command fails, and Error goes back to Running on Retry. Running goes to Closed when done, or after 1 second with a toast. Escape from any state closes and restores focus.ClosedBrowsingroot or a pageConfirmingdanger onlyRunningpending rowErrorinlineCmd+K, buttonTab, Backspace: pagesEnter, dangerEnterEnter againfailsRetrydoneEscape anywhere: close, focus back where it wasover 1 s running: close, a toast shows the result
Every path out ends in Closed with focus restored. Confirming is a page inside the palette, not a second dialog, so there is one focus trap and one Escape.

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.

A sequence diagram of running Archive issue, between the User, the Palette, the Command, the API, and the Toast. The user presses Enter on Archive issue. The palette marks the row pending and calls run. The command posts the archive request. The API replies 200. The command resolves with an undo. The palette closes and moves focus to a sensible element, and asks the toast to show Issue archived with Undo. On the failure path, the API replies 409, the command rejects, and the palette shows the error inline and stays open.UserPaletteCommandAPIToastpress Enterrun(ctx)POST archive200 OK{ undo }close, refocusArchived, Undoor 409rejectsinline error
The command knows how to archive and how to undo; the palette only shows progress, closes, and passes the 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?

A modal dialog with showModal()

The browser puts it in the top layer, above every z-index, and makes the rest of the page inert.

Escape fires a cancel event; ::backdrop dims the page.

Pros

  • No stacking bugs, built-in inert background, focus stays inside.
  • Same primitive as our modal-dialog component.

Cons

  • The page behind can't be clicked or scrolled while it's open.

A non-modal popover anchored to the search button

Use the popover attribute, also in the top layer, with light dismiss on outside click.

Pros

  • The page stays usable; feels like a search dropdown.

Cons

  • Tab can leave it into the page, which is confusing for a keyboard-first tool.
  • Less room for long results and nested pages.

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.

PartResponsibilityWhy the boundary is here
Shortcut listenerOne 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 shellThe trigger button and the lazy-loaded dialog.Keeps the first-load cost tiny; the heavy parts load on first open or on idle.
Command registryA store of commands from manifests and from mounted components, plus the current context.Features write, the palette reads. Twelve teams, no shared file.
MatcherPrepares commands once and scores them per keystroke.Pure functions, easy to test and to move into a worker later.
Server search clientDebounce, abort, a 60-second query cache, and exact-key lookups.All network timing in one place, shared with the global search page.
Result mergerCombines local, recent, and server results into ordered sections.The only place that knows ranking rules across sources.
Palette viewCombobox, listbox, page stack, breadcrumb, running and confirm states.Renders from state; holds no business logic.
History storeFrecency log in localStorage; telemetry events out.Survives reloads; shared with the empty-state suggestions.
The command palette's architecture. In the App shell, loaded on first paint: the Key listener and the Trigger button, both opening the Palette view. In Feature code: Manifests and the useCommands hook, both feeding the Registry. In the lazy Palette core: the Palette view, a combobox, writes the query to the Palette store; the Matcher reads the query and the Registry and sends matches to the Merger, which writes results to the store. In Services: the Search client sends entities to the Merger, and the History store sends frecency to the Merger.App shellKey listenerTriggerFeature codeManifestsuseCommandsPalette core (lazy)Palette viewdialog, comboboxMatcherfuzzy + frecencyRegistrywhen filterStorequery, stackMergersectionsServicesSearch clientdebounce, abortHistorylocalStorageentitiesfrecency
The shell is tiny and always loaded; the core loads on idle or first open. Features only ever talk to the Registry, and only the Search client talks to the network.

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.

Data flow for the palette. In the browser, the Palette view sends each query to the Matcher, which scores local commands and sends the top 50 to the Palette store. The Search client sends GET with the query to the Search API on the server and gets JSON back, which it writes to the store with setServer. The store renders the Palette view. Enter in the view calls command.run, which lazy-imports the feature and posts the change to the Issues API, getting 200 or 409 back. The command then hands its undo to the Toast.BrowserMatcherlocal commandsSearch client60 s query cacheStoresections, active idPalette viewrenders rowscommand.runlazy importToast + UndoServerSearch APIrankedIssues APIwritesGET ?q=JSONsetServerqueryEnterPOST200 / 409undo
Reads go through the Search client and its 60-second cache; writes go through the command that owns them. The store is the only thing the view renders from, which is why a stale reply can be dropped in one place.

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.

ts
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
Everything the palette view renders comes from this state.

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

http
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 request for every entity type, with a small limit per section.

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

tsx
// 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"]} />
Two ways in: a manifest for global commands, a hook for context ones.

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" with aria-labelledby pointing 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: reduce is 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

MomentBudgetHow we meet it
First page loadUnder 15 KB gzipped for listener and triggerEverything else is a dynamic import() prefetched on idle.
Shortcut to first frameUnder 100 msDialog shell renders before matching; input is focused in the same frame.
Keystroke to updated listUnder 16 ms for 5,000 itemsPrecomputed keys, narrowing, top 50 per section rendered.
Stop typing to server resultsAbout 300 ms at p75150 ms debounce, small limit, 60-second query cache.
Enter to visible resultUnder 100 ms for sync commandsPending 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:

  1. 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.
  2. Minutes 5 to 10: draw the wireframe and the architecture. Input, sections, rows with shortcuts, breadcrumb. Then registry, matcher, search client, merger, view.
  3. Minutes 10 to 20: the registry and its API. The Command type, manifests plus the hook, when, lazy run. This is the senior signal for a component question.
  4. 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.
  5. 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.
  6. 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?

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 run code for global commands, and a mount-time hook for commands that depend on the page or selection. A when condition 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.

Junior 0 of 3
Mid-level 0 of 4

References

Concepts behind it

Try next