Keyboard Shortcuts & Commands
Register and handle keyboard shortcuts in MV3 extensions using the chrome.commands API — manifest declaration, service worker listeners, per-OS suggested keys, the 4-shortcut limit, and cross-browser rebinding via chrome://extensions/shortcuts.
The most dangerous property of the chrome.commands API is what it does not tell you: invalid shortcut combinations are silently dropped at install time, leaving the feature simply absent with no console warning. Commands are declared statically in manifest.json and cannot be registered, removed, or reordered at runtime — every key binding your extension will ever support must exist in the manifest before the user installs it. This guide is part of UI/UX Patterns & Interactive Components and covers the full lifecycle from manifest declaration through service worker event routing to letting users rebind shortcuts via browser settings.
Prerequisites checklist
commandskey inmanifest.json— required to declare any shortcut. No runtime registration path exists.- Service worker entry point declared in
"background": { "service_worker": "background.js" }— this is whereonCommandlisteners live. - Max 4 shortcuts per extension — Chrome enforces a hard ceiling of four user-assignable commands. The special
_execute_actioncommand does not count against this limit. - Valid modifier combinations only — every
suggested_keyvalue must use at least one modifier (Ctrl,Alt,Shift,Command,MacCtrl). Modifier-only or bare function-key shortcuts (exceptF1–F12) are rejected silently. storagepermission — if handlers read or write state viachrome.storage, declare it in"permissions". See Chrome Storage API sync for quota and sync strategy.
1. Registering commands in manifest.json
Every command your extension supports must be declared in the commands object before shipping. The browser parses this key at install or update time and registers the shortcuts with the OS. Any combination the browser considers reserved or malformed is silently ignored — the command is simply absent.
The suggested_key object accepts four platform sub-keys: default (all platforms not explicitly listed), mac, linux, and windows. Omit a sub-key and the default value applies on that platform. On macOS, use Command for the Cmd key and MacCtrl for the hardware Ctrl key — these are distinct modifiers and mixing them up is the single most common macOS shortcut bug.
The special command name _execute_action triggers the extension’s toolbar action (equivalent to clicking the icon) and does not count against the four-shortcut limit. Use it to give power users a keyboard path to the popup without spending one of your four command slots.
1{
2 "manifest_version": 3,
3 "name": "My Extension",
4 "version": "1.0",
5 "commands": {
6 // Special built-in — opens the action popup, free slot
7 "_execute_action": {
8 "suggested_key": {
9 "default": "Alt+Shift+E",
10 "mac": "Command+Shift+E"
11 },
12 "description": "Open extension popup"
13 },
14 // Custom commands — count toward the 4-command limit
15 "toggle-feature": {
16 "suggested_key": {
17 "default": "Ctrl+Shift+Y",
18 "mac": "Command+Shift+Y",
19 "linux": "Ctrl+Shift+Y",
20 "windows": "Ctrl+Shift+Y"
21 },
22 "description": "Toggle primary feature on/off"
23 },
24 "quick-search": {
25 "suggested_key": {
26 "default": "Alt+Shift+S",
27 "mac": "Command+Shift+S"
28 },
29 "description": "Open quick search overlay"
30 }
31 }
32}
Execution context: manifest.json, evaluated by the browser at install or extension update time. No code runs here — these are static declarations. Changing a suggested_key requires publishing an updated extension and the user reloading or updating it. Invalid or reserved combinations are dropped without any error. Run chrome.commands.getAll() in the service worker DevTools console after loading the unpacked extension to confirm registrations.
2. Handling commands in the service worker
The chrome.commands.onCommand event fires in the extension’s service worker whenever a registered shortcut is pressed. Because MV3 service workers are ephemeral — terminated after roughly 30 seconds of inactivity and re-woken on demand — the listener must be registered synchronously at the top level of the background script. Placing it inside a callback, a promise chain, or after a top-level await risks missing events during cold starts.
1// background.js — service worker entry point
2
3// Register at module top level — never inside a callback or async block
4chrome.commands.onCommand.addListener(async (command) => {
5 switch (command) {
6 case 'toggle-feature':
7 await handleFeatureToggle();
8 break;
9 case 'quick-search':
10 await handleQuickSearch();
11 break;
12 default:
13 // _execute_action is handled by the browser, not dispatched here
14 console.warn('Unrecognised command:', command);
15 }
16});
17
18async function handleFeatureToggle() {
19 const { featureActive } = await chrome.storage.local.get('featureActive');
20 await chrome.storage.local.set({ featureActive: !featureActive });
21 await chrome.action.setBadgeText({ text: featureActive ? '' : 'ON' });
22}
23
24async function handleQuickSearch() {
25 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
26 if (tab?.id) {
27 await chrome.tabs.sendMessage(tab.id, { type: 'OPEN_SEARCH' });
28 }
29}
Execution context: Service worker (background.js). The onCommand listener fires regardless of which tab is active or whether the popup is open. The service worker may have been terminated since the last command — do not rely on global variable state between invocations. Persist all state to chrome.storage.local inside the handler before any await that could be interrupted. See implementing global keyboard shortcuts safely for the full cold-start timing analysis and a pattern for keeping handlers idempotent.
3. Letting users rebind shortcuts
Chrome provides no API to change a command’s binding programmatically — chrome.commands.update() does not exist. The only way users can reassign shortcuts is through the browser’s own shortcuts manager. Your extension can open that page directly from a popup or options page button to make discovery easier.
1// popup.js or options.js — runs in popup / options page context (not service worker)
2document.getElementById('btn-manage-shortcuts')
3 .addEventListener('click', () => {
4 chrome.tabs.create({ url: 'chrome://extensions/shortcuts' });
5 });
Execution context: Popup script or options page script. The chrome://extensions/shortcuts URL works in Chrome and Edge. In Firefox, users manage shortcut overrides at about:addons → the extension’s gear icon → Manage Extension Shortcuts. Safari does not expose a user-facing rebinding interface for extension commands.
Exposing a “Manage keyboard shortcuts” link in your options page or popup is the recommended practice. Pair it with a call to chrome.commands.getAll() to display the current binding so users know what they are changing before they click through.
1// Display current bindings in options page
2async function renderShortcutList(containerEl) {
3 const commands = await chrome.commands.getAll();
4 containerEl.innerHTML = commands
5 .filter(cmd => cmd.shortcut) // skip unassigned commands
6 .map(cmd => `<li><kbd>${cmd.shortcut}</kbd> — ${cmd.description}</li>`)
7 .join('');
8}
Execution context: Options page or popup script. chrome.commands.getAll() returns the currently active binding, which may differ from suggested_key if the user has reassigned it. Use this to show live bindings rather than hard-coding the manifest values into UI text. For strategies on detecting and resolving conflicts between your shortcuts and those of other installed extensions, see resolving keyboard shortcut conflicts.
4. Deciding which actions deserve a shortcut
The commands API allows four suggested key bindings, and one of those is usually taken by _execute_action to open the popup. That leaves three defaults for everything else, which forces a choice that is healthy to make deliberately.
A good candidate for a default key is used often, used while the user’s hands are on the keyboard, acts on the current page without further input, and is harmless if triggered by accident. “Save this page” and “next article” usually qualify; “open settings” does not, because it is rare and one click away; “delete everything” never does, however often it is used. Commands that fail these tests can still be declared without a suggested key, so users who want them can bind them on the browser’s shortcuts page — the approach in the _execute_action command and the four-shortcut limit.
Key choice matters as much as action choice. Alt+Shift with a letter collides least with browser and site shortcuts across platforms; plain Ctrl combinations collide almost everywhere; Ctrl+Alt combinations act as AltGr on many European layouts and can prevent users typing characters such as @. A binding that works on your keyboard can be unusable on your users'.
5. Shortcuts inside your own surfaces
Manifest commands are for reaching the extension from anywhere in the browser. Once a popup, side panel or options page has focus, it is an ordinary document, and ordinary key handlers give a much richer vocabulary with none of the limits: arrow keys to move through a list, Enter to open, / to jump to search, Escape to close.
1document.addEventListener("keydown", (e) => {
2 if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
3 if (e.key === "/") { e.preventDefault(); search.focus(); }
4 if (e.key === "j") move(+1);
5 if (e.key === "k") move(-1);
6});
Execution context: the popup or side panel document. Ignoring keys while an input has focus is essential — otherwise typing a j into the search box moves the selection instead. A small “?” overlay listing these keys makes them discoverable without cluttering the interface.
6. Making shortcuts discoverable and accurate
Shortcuts nobody knows about do not exist. Mention the popup shortcut in onboarding and in the popup itself until the user has used it once, and show bindings next to the actions they trigger. Always read the effective binding from chrome.commands.getAll() rather than printing the suggested key from the manifest: the user may have changed it, or the browser may have refused it because of a conflict, and a hint advertising a key that does nothing is worse than no hint. Linking to the browser’s shortcuts page from the options page — covered in letting users rebind extension shortcuts — gives users the one control the extension cannot provide itself.
7. Accessibility and the keyboard path
Keyboard commands are a convenience for most users and a necessity for some. People who navigate with a keyboard alone, with switch devices or with a screen reader depend on a complete keyboard path through the extension: a way to open it, a way to reach every control inside it, and a way to leave. The commands API provides the first step, and it is the step most often missing.
Give the popup a shortcut through _execute_action, even if nothing else has one. Inside the popup and other surfaces, keep the tab order following the visual order, give every icon-only button an accessible name, make focus visible, and make Escape close anything that opens on top of other content. Avoid single-key shortcuts that fire while a screen reader is in its browse mode — they can collide with the reader’s own navigation keys — or offer a way to turn them off.
Test the path by unplugging the mouse: open the extension from the keyboard, complete its main task, and return to the page. Every place where that fails is a place some users cannot use the extension at all. The screen-reader side of the same check is described in testing extension UI with a screen reader, and the focus-management patterns in making popups and options keyboard navigable.
Document the keyboard path in the extension itself, too. A short “Keyboard shortcuts” section on the options page listing the global commands with their current bindings, and the keys available inside each surface, costs a few lines and is the first place keyboard users will look. Keep it generated from chrome.commands.getAll() so it never goes stale when bindings change.
MV3 constraints to design around
- Static-only registration. Commands cannot be added, removed, or reordered at runtime. The
commandsobject inmanifest.jsonis the only registration mechanism. - Four-command limit. Chrome allows a maximum of four user-assignable commands per extension.
_execute_action,_execute_browser_action, and_execute_page_actionare special names that do not count against this limit. - Silent failure on invalid combos. Combinations that conflict with browser or OS reservations — for example
Ctrl+W,Ctrl+T,Cmd+Q,F12,Ctrl+Shift+I— are dropped without any warning in the console or manifest validation output. - No DOM access in the service worker. Command handlers run in the background context. To update page UI, use
chrome.scripting.executeScript()orchrome.tabs.sendMessage()to reach a content script. - Service worker is ephemeral. The handler may be the first code that runs after a 30-second idle termination. Never read from global variables to determine feature state — always hydrate from
chrome.storage.localat the start of the handler. - Global shortcuts fire only when the browser has focus. Shortcuts do not intercept key events at the OS level when the browser window is in the background; they fire only when the browser window is focused.
Cross-browser notes
Chrome and Edge share the same chrome.commands surface and the four-command limit. Both use chrome://extensions/shortcuts for user rebinding. Firefox implements the same manifest syntax under the WebExtensions standard and fires the same onCommand event, but uses the browser.commands namespace — use a polyfill (like webextension-polyfill) or a runtime check to normalise the namespace.
1// Cross-browser namespace normalisation
2const ext = typeof browser !== 'undefined' ? browser : chrome;
3
4ext.commands.onCommand.addListener((command) => {
5 // handler runs identically on Chrome, Edge, and Firefox
6});
Execution context: Service worker or background script. The ternary resolves at module evaluation time — there is no build-time branching required. Firefox MV3 also supports _execute_action as a special command name matching Chrome’s behaviour. In Firefox, about:addons is the user-facing shortcut manager, not chrome://extensions/shortcuts.
Safari’s WebKit implementation does not support the commands manifest key for MV3 extensions at this time. Shortcut handling on Safari requires falling back to keydown listeners in content scripts or popup pages, which covers only those specific page contexts rather than providing a global trigger.
| Browser | chrome.commands support | Rebinding UI | _execute_action |
|---|---|---|---|
| Chrome | Full | chrome://extensions/shortcuts | Supported |
| Edge | Full (Chromium) | edge://extensions/shortcuts | Supported |
| Firefox | Full (browser.commands) | about:addons | Supported |
| Safari | Not supported in MV3 | n/a | n/a |
When testing shortcut registrations after a manifest change, always force-reload the unpacked extension from chrome://extensions rather than relying on hot-reload tooling — some bundler watchers do not trigger the full manifest re-parse that registers new command entries.
Further guides in this topic
The guides below go deeper into specific keyboard shortcuts and commands problems that the sections above only touch on — each one starts from a concrete symptom and ends with a way to verify the fix.
- Letting Users Rebind Extension Shortcuts — Give users control of extension shortcuts — linking to the browser’s shortcuts page, Firefox’s commands.update, detecting changes, and keeping hints in your UI accurate.
- Shortcut Behaviour in Firefox and Safari — How extension keyboard commands differ outside Chrome — Firefox’s reserved commands, key-name rules and commands.update, Safari’s macOS-only modifiers and gesture rules — and one manifest for all three.
- The _execute_action Command and the Four-Shortcut Limit — How MV3’s reserved _execute_action command opens the popup, why only four commands can have suggested keys, and how to decide which actions earn a default shortcut.
Related
- Implementing global keyboard shortcuts safely — cold-start listener timing, reserved-key validation checklist, idempotent handler patterns.
- Resolving keyboard shortcut conflicts — detecting collisions with other extensions, guiding users through rebinding, fallback strategies.
- Popup Interface Design — pair shortcut-triggered actions with popup state, open the popup programmatically via
_execute_action. - Options Page Layouts — surface the shortcut manager link and live binding display in a dedicated settings UI.
- Service Worker Fundamentals — ephemeral lifecycle, top-level listener registration, state hydration on wake.
- Chrome Storage API sync — persist feature state that command handlers read and write.
- Up to UI/UX Patterns & Interactive Components.