Building a Command Palette in an Extension

Build a keyboard-first command palette for an MV3 extension: opening it with a command, an accessible combobox and listbox, fuzzy matching, running actions in the right context, and an in-page palette in shadow DOM.

Published October 2, 2026 Updated October 2, 2026 8 min read
Table of Contents

The extension now has fourteen actions, the popup has run out of room, and Chrome allows only four suggested shortcuts. Power users want one key that opens a search box where they type “exp” and press Enter to export. That is a command palette — the pattern popularised by code editors — and it suits extensions well: one shortcut gives access to every action, new actions need no new UI, and the palette doubles as a discoverable list of what the extension can do. This guide builds one in the popup, makes it accessible as a combobox, routes actions to the service worker or the page, and shows the in-page variant. It belongs to keyboard shortcuts and commands.

The architecture of a palette

A palette has four parts. A registry lists actions with an id, a label, keywords, an optional command name (for shortcut hints) and where it runs. A surface shows a text input and a filtered list — the popup opened by _execute_action is the simplest surface, because it already has focus and closes on Escape; an overlay injected into the page is the alternative when the palette should feel part of the page. A matcher ranks actions against the query. A dispatcher runs the chosen action: most actions are messages to the service worker, which has the APIs and outlives the popup; some run in the page through chrome.scripting.

Command palette architectureA shortcut opens the popup palette; the user types; the matcher ranks registry actions; Enter dispatches the selected action as a message to the service worker, which runs it, possibly injecting into the active tab, then the popup closes.Shortcut_execute_actionPopup paletteinput + listboxMatcherrank registryEnter → dispatchruntime.sendMessage{action, tabId}Service workerruns the actionOptional injectionscripting into tab
The popup shows and chooses; the worker executes.

Step-by-step: a palette in the popup

1. Define the action registry

1// actions.js — shared by popup and worker
2export const ACTIONS = [
3  { id: "save",      label: "Save page",               keywords: ["bookmark", "add"], command: "save-page", runs: "worker" },
4  { id: "export",    label: "Export library…",         keywords: ["download", "json", "backup"],           runs: "worker" },
5  { id: "reader",    label: "Toggle reader view",       keywords: ["focus", "clean"], command: "toggle-reader", runs: "page" },
6  { id: "highlight", label: "Highlight selection",      keywords: ["mark", "annotate"],                     runs: "page" },
7  { id: "notes",     label: "Open notes in side panel", keywords: ["sidebar"], command: "open-notes",       runs: "worker" },
8  { id: "options",   label: "Settings",                 keywords: ["preferences", "options"],               runs: "worker" },
9];

Execution context: a module imported by both the popup and the service worker. Labels should be localised (chrome.i18n.getMessage) in a real build. Keywords catch synonyms users type. command links an action to its commands entry so the palette can show its shortcut.

2. Build accessible markup

1<!-- popup.html -->
2<div class="palette">
3  <input id="q" role="combobox" aria-expanded="true" aria-controls="results" aria-autocomplete="list"
4         aria-activedescendant="" aria-label="Type a command" autocomplete="off" autofocus>
5  <ul id="results" role="listbox" aria-label="Commands"></ul>
6</div>

Execution context: the popup page. The combobox-with-listbox pattern keeps DOM focus in the input while aria-activedescendant points to the highlighted option, so screen readers announce each option as the user arrows through, and typing never loses its place. autofocus puts the cursor in the input as soon as the popup opens.

Running a palette actionThe user opens the palette with a shortcut, types exp, arrows to Export library, presses Enter; the popup sends a message to the worker with the action and tab id and closes; the worker runs the export.UserPopup paletteService workerAlt+Shift+R → palette openstypes "exp", EntersendMessage({run:'export', tabId})window.close()export → downlo…
Send, then close — the worker finishes the job.

3. Rank actions with a small fuzzy matcher

 1// match.js
 2export function score(action, q) {
 3  if (!q) return 1;
 4  const hay = [action.label, ...action.keywords].join(" ").toLowerCase();
 5  q = q.toLowerCase();
 6  if (action.label.toLowerCase().startsWith(q)) return 100;
 7  if (hay.includes(q)) return 50;
 8  let i = 0;                                   // subsequence: "exl" matches "EXport Library"
 9  for (const ch of hay) if (ch === q[i]) i++;
10  return i === q.length ? 10 : 0;
11}
12
13export function rank(actions, q, recent = []) {
14  return actions
15    .map((a) => ({ a, s: score(a, q) + (recent.includes(a.id) ? 5 : 0) }))
16    .filter((x) => x.s > 0)
17    .sort((x, y) => y.s - x.s)
18    .map((x) => x.a);
19}

Execution context: the popup. Prefix matches beat substring matches, which beat subsequence matches; a small boost for recently used actions makes the palette learn. For dozens of actions this is instant; for hundreds of dynamic items (saved pages, tabs) consider a library such as Fuse.js.

4. Render results and handle keys

 1// popup.js
 2import { ACTIONS } from "./actions.js";
 3import { rank } from "./match.js";
 4import { loadBindings, shortcutFor } from "./hints.js";
 5
 6const q = document.querySelector("#q"), list = document.querySelector("#results");
 7let results = [], active = 0;
 8await loadBindings();
 9const { recent = [] } = await chrome.storage.local.get("recent");
10
11function render() {
12  results = rank(ACTIONS, q.value.trim(), recent);
13  active = Math.min(active, Math.max(results.length - 1, 0));
14  list.replaceChildren(...results.map((a, i) => {
15    const li = document.createElement("li");
16    li.id = `opt-${a.id}`; li.role = "option"; li.textContent = a.label;
17    li.setAttribute("aria-selected", String(i === active));
18    const sc = a.command && shortcutFor(a.command);
19    if (sc) li.append(Object.assign(document.createElement("kbd"), { textContent: sc }));
20    li.addEventListener("click", () => run(a));
21    return li;
22  }));
23  q.setAttribute("aria-activedescendant", results[active] ? `opt-${results[active].id}` : "");
24  list.children[active]?.scrollIntoView({ block: "nearest" });
25}
26
27q.addEventListener("input", () => { active = 0; render(); });
28q.addEventListener("keydown", (e) => {
29  if (e.key === "ArrowDown") { active = (active + 1) % results.length; render(); e.preventDefault(); }
30  if (e.key === "ArrowUp")   { active = (active - 1 + results.length) % results.length; render(); e.preventDefault(); }
31  if (e.key === "Enter" && results[active]) run(results[active]);
32});
33render();

Execution context: the popup. Arrow keys wrap; Enter runs the highlighted action; mouse clicks also work. Escape needs no handler — it closes the popup. Showing each action’s shortcut teaches users the direct route, as in showing shortcut hints in extension UI.

5. Dispatch to the worker, then close

1async function run(action) {
2  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
3  await chrome.storage.local.set({ recent: [action.id, ...recent.filter((id) => id !== action.id)].slice(0, 5) });
4  chrome.runtime.sendMessage({ type: "palette-run", id: action.id, tabId: tab?.id });
5  window.close();
6}

Execution context: the popup. The popup closes immediately; the service worker does the work, so long actions are not cut off when the popup disappears. See running work that outlives the popup. Opening the popup grants activeTab, so page actions can inject into the tab without host permissions.

Popup palette versus in-page paletteA palette in the action popup and one injected into the page compared on how it opens, focus handling, permissions, conflicts with page shortcuts and where it works.SurfaceOpens viaPermissionsWorks on browser pagesPopup palette_execute_actionactiveTabYesIn-page overlayCommand → injectactiveTab + scriptingNoSide panel paletteCommand → open panelsidePanelYes
Start with the popup; add an in-page palette only if it must feel part of the page.

6. Execute actions in the worker

 1// sw.js
 2chrome.runtime.onMessage.addListener((msg) => {
 3  if (msg.type !== "palette-run") return;
 4  const handlers = {
 5    save:      () => savePage(msg.tabId),
 6    export:    () => exportLibrary(),
 7    reader:    () => chrome.scripting.executeScript({ target: { tabId: msg.tabId }, files: ["reader.js"] }),
 8    highlight: () => chrome.scripting.executeScript({ target: { tabId: msg.tabId }, func: highlightSelection }),
 9    notes:     () => chrome.sidePanel.open({ tabId: msg.tabId }),
10    options:   () => chrome.runtime.openOptionsPage(),
11  };
12  handlers[msg.id]?.().catch((e) => reportError(e, { action: msg.id }));
13});

Execution context: the service worker. One dispatch table maps ids to implementations, and the same functions can back the commands.onCommand handlers so palette and shortcuts never diverge. Note that sidePanel.open requires a user gesture; a message sent from the popup during the click usually carries it, but test this path.

7. Build an in-page palette when needed

When the palette should appear over the page (for page-centric actions like “jump to heading”), handle a command in the worker that injects a content script, which renders the same input and listbox inside a closed shadow root with <dialog>.showModal() for focus containment. See managing focus in popups and dialogs and keeping injected UI above the page with the top layer.

8. Include dynamic items carefully

Palettes become more useful when they include data — open tabs, saved pages, recent highlights. Load these lazily after the first render so the popup opens instantly, and cap the number shown. Keep a visible group label (“Actions”, “Saved pages”) so users understand what they are choosing.

Common mistakes

  • Moving DOM focus into the list. Typing then stops working; use aria-activedescendant.
  • Running long work in the popup. It dies when the popup closes.
  • Separate implementations for palette and shortcuts. They drift; share handlers.
  • No synonyms. Users type “download” and find nothing for “Export”.
  • Slow first render. Load dynamic items after the actions are shown.

Cross-browser variation

  • Chrome / Edge: popup palette via _execute_action; sidePanel.open from a palette action needs a user gesture.
  • Firefox: _execute_browser_action in MV2 and _execute_action in MV3 open the popup; the sidebar is opened with sidebarAction.open.
  • Safari: popovers work as a palette surface; the shortcut to open the popup depends on Safari’s command support — test it.

Verification

  1. Press the popup shortcut and type immediately — characters should land in the input.
  2. Arrow through results with a screen reader and confirm each option is announced.
  3. Run a long action and confirm it completes after the popup closes.
  4. Run the same action from the palette and from its shortcut and confirm identical behaviour.

FAQ

Can the palette open with Ctrl+K inside pages?

Only via a content script keydown listener, which may conflict with sites. Prefer a browser command.

How many actions before a palette is worth it?

Around eight. Below that, buttons in the popup are clearer.

Should results include help articles?

Searching help from the palette is useful; open results in a new tab and label them clearly.

Other UI/UX Patterns & Interactive Components Resources