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.
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.
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.
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.
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.openfrom a palette action needs a user gesture. - Firefox:
_execute_browser_actionin MV2 and_execute_actionin MV3 open the popup; the sidebar is opened withsidebarAction.open. - Safari: popovers work as a palette surface; the shortcut to open the popup depends on Safari’s command support — test it.
Verification
- Press the popup shortcut and type immediately — characters should land in the input.
- Arrow through results with a screen reader and confirm each option is announced.
- Run a long action and confirm it completes after the popup closes.
- 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.
Related
- Showing shortcut hints in extension UI — shortcuts beside results.
- Search and filter UI in a popup — related list techniques.
- The
_execute_actioncommand and the four-shortcut limit — why a palette helps. - Keyboard shortcuts and commands — the parent topic.