Handling Shortcuts in Content Scripts

Add page-level keyboard shortcuts from an MV3 content script: capture-phase listeners, avoiding text fields, not fighting the page, and when the commands API is the better answer.

Published July 25, 2026 Updated July 25, 2026 9 min read
Table of Contents

The commands API gives you four global shortcuts and no more, so anything richer — a key that only applies on one site, a sequence, a key that depends on what the user has selected — has to come from a content script listening for keydown. That listener runs inside a page you do not control, alongside the page’s own handlers, and it is trivially easy to break typing on the site you are trying to improve. This page is part of Keyboard Shortcuts & Commands and covers page-level shortcuts that behave.

Root cause: you are a guest in the page’s event stream

A content script listener sits in the same event path as the site’s own. Whether you see an event first depends on the phase you registered for; whether the site sees it afterwards depends on whether you stopped it. Neither default is right for every case, and getting it wrong produces either a shortcut that never fires or a page where the user cannot type.

A keystroke through the event pathThe event travels down through capture-phase listeners to the target and back up through bubble-phase listeners, with your script able to register in either.window (capture)your listener can go heredocument (capture)page listeners often hereTarget elementinput, editor, bodythen back up through the bubble phaseTarget (bubble)site shortcut handlersdocument (bubble)site fallbackswindow (bubble)safest place to be last
Capture sees the event before the page; bubble sees it after the page has had its chance to cancel it.

Step 1. Choose the phase deliberately

Register in the bubble phase by default: the page gets its chance first, and you only act on keystrokes nobody claimed. Use capture only when you genuinely need to pre-empt the site, and accept that doing so can break its own shortcuts.

1// content.js
2const handler = (event) => { /* … */ };
3
4// Default: last in line. If the page handled and cancelled it, we never run.
5window.addEventListener("keydown", handler, { passive: false });
6
7// Only when pre-empting is genuinely required — for example, replacing a site's own
8// broken shortcut at the user's explicit request.
9// window.addEventListener("keydown", handler, { capture: true });

Execution context: Content script in the page’s isolated world. The event objects are shared with the page even though the JavaScript heaps are not, so defaultPrevented reflects what the site’s handlers did — which is exactly the signal you want in the bubble phase. Registering on window rather than document puts you outside most site handlers in both phases, making the position predictable.

Step 2. Never steal a keystroke meant for text

The single most damaging bug in this area is a shortcut that fires while the user is typing. The check is not “is the target an input” — it also has to cover contenteditable regions, shadow roots and composition input.

 1// content.js
 2function isTextEntry(event) {
 3  // composed path sees through shadow roots; fall back to target for older engines.
 4  const path = typeof event.composedPath === "function" ? event.composedPath() : [event.target];
 5  const el = path[0];
 6  if (!(el instanceof Element)) return false;
 7
 8  if (el.isContentEditable) return true;
 9
10  const tag = el.tagName;
11  if (tag === "TEXTAREA" || tag === "SELECT") return true;
12  if (tag === "INPUT") {
13    const type = (el.getAttribute("type") || "text").toLowerCase();
14    // Buttons and checkboxes are not text entry; everything else in an input is.
15    return !["button", "submit", "reset", "checkbox", "radio", "file", "image"].includes(type);
16  }
17  return false;
18}
19
20function handler(event) {
21  if (event.isComposing || event.keyCode === 229) return;   // IME composition in progress
22  if (isTextEntry(event)) return;
23  if (event.defaultPrevented) return;                        // the page already claimed it
24  // …
25}

Execution context: Content script. The IME check matters on every engine and is invisible in English-only testing: while a composition is active, key events carry isComposing and must be ignored entirely, or the shortcut fires mid-word for users typing Japanese, Chinese or Korean. composedPath is what finds the real target inside a web component, which is otherwise reported as the host element.

Step 3. Match the combination without guessing at layouts

event.key reflects the character the layout produces, which differs between keyboards; event.code reflects the physical key, which does not. For letter shortcuts, code is usually the better match because a shortcut on the physical KeyK stays in the same place on every layout.

 1// content.js
 2const BINDINGS = [
 3  { code: "KeyK", ctrl: true, shift: true, action: "open-search" },
 4  { code: "Slash", ctrl: false, shift: false, action: "focus-filter" },
 5];
 6
 7function matches(event, binding) {
 8  if (event.code !== binding.code) return false;
 9  // Treat Ctrl and Cmd as the same modifier so one binding covers both platforms.
10  const primary = event.ctrlKey || event.metaKey;
11  if (Boolean(binding.ctrl) !== primary) return false;
12  if (Boolean(binding.shift) !== event.shiftKey) return false;
13  if (event.altKey) return false;                 // never match when an unlisted modifier is held
14  return true;
15}

Execution context: Content script. Collapsing Ctrl and Cmd into one “primary” modifier is what lets a single binding table serve macOS and Windows, and rejecting unlisted modifiers stops Ctrl+Alt+K from triggering a Ctrl+K binding — which matters because Ctrl+Alt combinations produce characters on several European layouts.

Content-script shortcuts against the commands APIScope, limits, whether the page can block them, and whether they work with no page open, compared between the two mechanisms.PropertyContent script keydownchrome.commandsNumber availableUnlimitedFour suggestedUser can rebindOnly if you build itBrowser UIPage can block itYesNoWorks on browser pagesNoYesWorks with no page focusedNoYesCan be site-specificYesNo
The bottom two rows are the deciding ones: only commands work when no page is focused, and only content scripts can be site-specific.

Step 4. Act through the worker, not in the page

The content script should recognise the keystroke and delegate. It has almost no API surface of its own, and keeping the action in the service worker means one implementation serves both the content-script binding and the commands API binding.

From keystroke to action, with the page still workingThe content script filters out text entry and page-claimed events, matches the binding, cancels the event and delegates the action to the service worker.PageContent scriptService workerkeydown reaches the bubble phaseisComposing? text entry? already claimed?match against the binding tablepreventDefault + stopPropagationonly once matchedsendMessage({ action })same handler as the commands binding
Three rejections happen before anything is cancelled — which is why typing on the page is never affected.
 1// content.js
 2function handler(event) {
 3  if (event.isComposing || isTextEntry(event) || event.defaultPrevented) return;
 4
 5  const binding = BINDINGS.find((b) => matches(event, b));
 6  if (!binding) return;
 7
 8  // Claim the keystroke only once we are sure it is ours.
 9  event.preventDefault();
10  event.stopPropagation();
11
12  void chrome.runtime.sendMessage({ type: "SHORTCUT", action: binding.action });
13}

Execution context: Content script in the isolated world. Calling preventDefault after the match rather than before is what keeps unmatched keystrokes untouched — a handler that cancels first and decides later is how a shortcut extension breaks a site’s own keyboard support. stopPropagation prevents the site’s later handlers from also acting on a key you have just claimed, which is usually what the user expects once they have opted into your shortcut.

Step 5. Let the user turn them off per site

Page-level shortcuts conflict with site shortcuts sooner or later, and the only good answer is a per-origin switch the user controls.

 1// content.js — check once, then react to changes
 2let enabled = true;
 3
 4(async () => {
 5  const origin = location.origin;
 6  const { shortcutsOff = [] } = await chrome.storage.sync.get("shortcutsOff");
 7  enabled = !shortcutsOff.includes(origin);
 8})();
 9
10chrome.storage.onChanged.addListener((changes, area) => {
11  if (area !== "sync" || !changes.shortcutsOff) return;
12  enabled = !(changes.shortcutsOff.newValue ?? []).includes(location.origin);
13});

Execution context: Content script, with handler returning early when enabled is false. Reading once and then following onChanged avoids a storage read per keystroke, which would be a real cost on a page where the user types continuously. Exposing the toggle from the popup makes it reachable at the moment the conflict is noticed, which is the only moment the user will look for it.

Cross-browser variation

  • Chrome 120+: event.code, composedPath and isComposing all behave as specified. Content scripts cannot run on the web store or browser pages, so shortcuts are simply absent there.
  • Firefox 121+: identical event semantics. Its content scripts are subject to the same restricted-page rules, with a slightly different set of restricted origins.
  • Safari 17+: the same event model; some system-level combinations are consumed by the OS before the page sees them, so avoid Command-heavy bindings.
  • All engines: a page can cancel the event before you see it in the bubble phase. That is by design — capture-phase registration is the override, and it should be a user-visible choice.

Verification

  1. Focus a text field on a page with a binding registered and type the shortcut characters. Nothing should fire.
  2. Switch a keyboard layout to AZERTY and confirm the code-based binding still lands on the same physical key.
  3. Start an IME composition and type through the binding. It must not fire mid-composition.
  4. Turn the shortcuts off for the origin and confirm the handler returns early with no message sent.

FAQ

Should I use event.key or event.code?

code for anything positional, which is most letter shortcuts. key when the character itself is the point — a shortcut on ? genuinely means the question mark, wherever the layout puts it.

Why does my shortcut not fire on some sites?

Either a content script cannot run there at all — browser pages, the web store — or the site cancelled the event before the bubble phase reached you. Check defaultPrevented in a capture-phase probe to tell the two apart.

Is it acceptable to use capture phase?

Sometimes, but treat it as taking something from the page. Reserve it for cases where the user explicitly asked you to override a site behaviour, and make it reversible.

When should I use the commands API instead?

Whenever the shortcut should work regardless of what is focused, including on pages you cannot inject into. Commands also give the user a rebinding interface for free, which a content-script binding does not.

Other UI/UX Patterns & Interactive Components Resources