Implementing Global Keyboard Shortcuts Safely
Learn how to implement global keyboard shortcuts in Chrome MV3 extensions using declarative manifest commands and chrome.commands.onCommand without DOM access.
Learn how to implement global keyboard shortcuts in Chrome MV3 extensions using declarative manifest commands and chrome.commands.onCommand without DOM access.
Extensions that register keydown listeners in the background context will find them silently non-functional in Manifest V3. The document object does not exist inside a service worker, so document.addEventListener('keydown', …) throws at runtime and no keystroke is ever captured. This page covers the correct approach: declarative manifest registration combined with the chrome.commands API. For a broader view of input handling patterns, see Keyboard Shortcuts & Commands.
MV3 background scripts run as ephemeral service workers, not as persistent background pages. The browser may terminate a service worker after roughly 30 seconds of inactivity and restart it on demand. Because service workers execute outside the browser’s rendering pipeline, the entire DOM API — including document, window, and all event targets tied to a page — is unavailable. Any shortcut implementation that depends on DOM event capture therefore fails at the architectural level, not just as a runtime quirk. The chrome.commands API sidesteps this by routing keyboard events through the browser’s native input handling layer directly to the extension runtime, independent of any document context.
Declare every shortcut your extension needs inside the commands object of manifest.json. The browser reads this at install time and registers the bindings with the OS-level shortcut manager. No runtime code is involved at this stage.
Provide per-OS keys using the suggested_key object. Use "default" for Windows and Linux and "mac" for macOS. Modifier names are case-sensitive: Ctrl, Alt, Shift, Command, and MacCtrl (the physical Control key on macOS, distinct from Command). The _execute_action special command connects a key binding to the extension’s toolbar action without writing any listener code.
Chrome enforces a limit of four shortcuts per extension. Registering a fifth causes earlier entries to be silently ignored, so budget carefully. Never use reserved combinations such as Ctrl+W, Cmd+Shift+I, F12, or Alt+Tab — the browser drops them without a console warning.
1{
2 "name": "My Extension",
3 "manifest_version": 3,
4 "commands": {
5 "_execute_action": {
6 "suggested_key": {
7 "default": "Alt+Shift+E",
8 "mac": "Command+Shift+E"
9 },
10 "description": "Open the extension popup"
11 },
12 "toggle-feature": {
13 "suggested_key": {
14 "default": "Ctrl+Shift+Y",
15 "mac": "Command+Shift+Y"
16 },
17 "description": "Toggle feature state"
18 }
19 }
20}
Execution context: manifest.json is evaluated at extension install and update time by the browser’s extension loader. This is a build-time declaration — no runtime JavaScript runs here.
Wire up chrome.commands.onCommand as a synchronous, top-level statement in your service worker entry point (background.js). The browser fires this event whenever the user presses a registered combination, regardless of which tab is active or whether the popup is open.
Do not place the listener inside an async function, a Promise chain, or any deferred callback. If execution reaches addListener only after an await, the service worker may miss commands that arrive during a cold-start wake cycle.
Keep handler logic lightweight. The service worker can be terminated between the user pressing a key and any subsequent async operation completing. Offload state changes to chrome.storage.local immediately so that a mid-flight termination does not leave the extension in an inconsistent state (see Step 4 below).
1// background.js — top-level registration, no async wrapper
2chrome.commands.onCommand.addListener((command) => {
3 if (command === 'toggle-feature') {
4 chrome.action.setBadgeText({ text: 'ON' });
5 }
6});
Execution context: background.js service worker entry point. The listener must be registered synchronously during initial script evaluation so the browser can attach it before any event fires.
Chrome does not expose a programmatic API for modifying shortcut bindings at runtime — chrome.commands.update() does not exist. Users who want to change a binding must do so manually. Direct them to the shortcuts management page from your popup or options page.
1// popup.js or options.js (runs in a normal page context, not a service worker)
2document.getElementById('btn-open-shortcuts').addEventListener('click', () => {
3 chrome.tabs.create({ url: 'chrome://extensions/shortcuts' });
4});
Execution context: popup page script or options page script — a regular DOM environment. The chrome://extensions/shortcuts URL is supported in Chrome and Edge. Firefox users manage shortcuts at about:addons; Safari does not provide a built-in shortcut manager for extensions.
Because the service worker can be terminated at any point — including mid-handler — any state that must survive across invocations needs to be written to chrome.storage.local before the handler returns. Read the persisted state back when the service worker restarts.
1// background.js
2chrome.commands.onCommand.addListener(async (command) => {
3 if (command === 'toggle-feature') {
4 const { featureEnabled } = await chrome.storage.local.get('featureEnabled');
5 const next = !featureEnabled;
6 await chrome.storage.local.set({ featureEnabled: next });
7 chrome.action.setBadgeText({ text: next ? 'ON' : '' });
8 }
9});
10
11// Restore badge on service worker wake
12chrome.runtime.onStartup.addListener(async () => {
13 const { featureEnabled } = await chrome.storage.local.get('featureEnabled');
14 if (featureEnabled) {
15 chrome.action.setBadgeText({ text: 'ON' });
16 }
17});
Execution context: background.js service worker. chrome.storage.local is the durable store that persists across service worker lifecycles. See Chrome Storage API & Sync for storage limits and quota management.
chrome.commands API support. Shortcuts are managed at chrome://extensions/shortcuts. The _execute_action command maps to the browser action. Maximum four custom commands per extension.browser.commands (the WebExtensions namespace) with identical manifest syntax. Users manage shortcuts at about:addons under the extension’s options. The _execute_action command name is supported for the browser action shortcut. Firefox does not enforce a four-shortcut limit.chrome://extensions/shortcuts equivalent. Users can enable shortcuts manually in System Settings > Privacy & Security > Extensions, but support is partial and varies by macOS version. Do not rely on keyboard commands as a primary interaction pattern in Safari-targeted extensions.chrome://extensions/shortcuts URL and the four-command limit. Shortcut registration may be delayed until the first user interaction after install in some Edge versions.Use chrome.commands.getAll() from the service worker console to inspect which shortcuts are currently active.
chrome://extensions and enable Developer mode.1chrome.commands.getAll(commands => console.log(JSON.stringify(commands, null, 2)));
Execution context: DevTools console connected to the extension’s service worker.
Expected output lists each registered command with its name, description, and shortcut fields. If a command appears with an empty shortcut string, the binding failed — most likely due to a reserved key conflict, a manifest syntax error, or an enterprise policy override. Cross-reference the returned names against your manifest.json source; a mismatch usually indicates a stale build cache. Clear the extension and reload it from disk to resolve stale registrations.
To confirm the handler fires, add a temporary console.log inside onCommand and watch the service worker console while pressing the shortcut.
Three causes account for the majority of cases. First, the key combination overlaps with a reserved OS or browser shortcut (Ctrl+W, Cmd+Shift+I, F12, Alt+Tab are common culprits). Second, there is a syntax error in the commands block of manifest.json — mismatched modifier casing or a missing suggested_key key causes the entire command entry to be dropped without a warning. Third, a managed enterprise policy may block shortcut customization for all extensions. Run chrome.commands.getAll() to determine which of these applies.
No. Chrome enforces a hard limit of four custom keyboard commands per extension. Attempting to declare a fifth causes one or more entries to be ignored. Plan your shortcut budget carefully and prefer the _execute_action special command for the primary action to avoid consuming one of your four slots.
The most common reason is that the listener was registered inside an async function rather than at the top level of the service worker. If the service worker wakes from a terminated state, script evaluation begins from the top; any listener inside a deferred callback may not be attached before the event fires. Move all chrome.commands.onCommand.addListener calls to the synchronous top level of background.js.
You cannot resolve conflicts programmatically. The correct approach is to guide users to chrome://extensions/shortcuts (use chrome.tabs.create as shown in Step 3) with clear UI copy explaining what to do there. Document the default bindings in your extension’s options page and provide suggested alternatives for common conflicts. The Resolving Keyboard Shortcut Conflicts page covers the conflict resolution workflow in detail.
Implement persistent side panels and DevTools extension panels in MV3 using chrome.sidePanel and chrome.devtools.panels — per-tab control, lifecycle, and cross-browser gaps.
Build chrome.contextMenus items in MV3: register in onInstalled, handle onClicked in the service worker, create parent/child menus, and gate visibility by context type.
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.
Design scalable options page UIs for MV3 extensions — sidebar nav, tabbed sections, responsive grids, form components, autosave UX, and dark mode support.