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.
Table of Contents
The commands manifest key is shared by all three engines, which makes it tempting to assume shortcuts behave the same everywhere. They mostly do — until a Firefox user reports that your _execute_action key opens nothing, or a Safari user cannot find where to change a binding. The differences are small, specific and easy to design around once you know them. This guide is part of keyboard shortcuts and commands.
The differences that matter
Step-by-step
1. Write one commands block that all three accept
1{
2 "commands": {
3 "_execute_action": {
4 "suggested_key": { "default": "Alt+Shift+R", "mac": "Alt+Shift+R" },
5 "description": "__MSG_cmdOpenPopup__"
6 },
7 "save-page": {
8 "suggested_key": { "default": "Alt+Shift+S", "mac": "Alt+Shift+S" },
9 "description": "__MSG_cmdSavePage__"
10 }
11 }
12}
Execution context: parsed at install by each engine. Stating the mac binding explicitly avoids Chrome’s automatic Ctrl→Command remapping producing a different key on macOS than you tested. Alt+Shift+<letter> is the combination most likely to be accepted unchanged by all three browsers.
2. Handle Firefox’s sidebar command in the per-target manifest
_execute_side_panel means nothing to Firefox, and _execute_sidebar_action means nothing to Chrome. Put each in its own target.
1// build/manifest.mjs
2if (target === "chrome") {
3 m.commands._execute_side_panel = { suggested_key: { default: "Alt+Shift+P" }, description: "__MSG_cmdPanel__" };
4}
5if (target === "firefox") {
6 m.commands._execute_sidebar_action = { suggested_key: { default: "Alt+Shift+P" }, description: "__MSG_cmdPanel__" };
7}
Execution context: the build, in Node. An unknown reserved name is either rejected or shown as an unbindable command, depending on the engine — generating per target avoids both. The generator is described in generating a manifest per browser target.
3. Mind Firefox’s key-name rules
Firefox validates suggested_key strictly. Keys must be a single letter, digit, F1–F12, or a named key such as Comma, Period, Home, PageUp, Space, Up/Down/Left/Right. Punctuation written as the character itself is rejected.
1// Rejected by Firefox — punctuation as a literal character
2"suggested_key": { "default": "Alt+Shift+," }
3
4// Accepted by all three
5"suggested_key": { "default": "Alt+Shift+Comma" }
Execution context: parsed at install. A rejected suggested_key in Firefox can fail the whole manifest in some versions, which makes this worth linting — web-ext lint reports it.
4. Use commands.update on Firefox, and only there
1export async function setShortcut(name, shortcut) {
2 const api = globalThis.browser ?? globalThis.chrome;
3 if (!api.commands.update) return { ok: false, reason: "edit-in-browser" };
4 try { await api.commands.update({ name, shortcut }); return { ok: true }; }
5 catch (e) { return { ok: false, reason: e.message }; }
6}
Execution context: the options page. The capability check chooses between an inline editor and a link to the browser’s shortcuts page, as described in letting users rebind extension shortcuts. Returning a reason rather than throwing lets the UI explain what to do.
5. Treat Safari’s command as a gesture, and test it there
Safari delivers commands.onCommand and honours _execute_action, and a command counts as a user gesture for activeTab. Two practical differences: Safari does not support global shortcuts at all, and some Alt-based combinations type special characters on macOS keyboards and are consumed before the browser sees them.
1chrome.commands.onCommand.addListener(async (command, tab) => {
2 if (command !== "save-page") return;
3 if (!tab?.id) {
4 // Safari may omit tab on some versions — fall back to querying.
5 [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
6 }
7 return savePage(tab);
8});
Execution context: the background context on each engine. The fallback covers engines and versions that do not pass the tab to onCommand; the query is safe because the command itself was the gesture.
macOS modifiers across the three engines
Most of the practical cross-browser trouble with shortcuts is actually macOS trouble. Three facts cover it.
Ctrl in a manifest binding becomes Command on macOS in Chrome and Firefox unless you use MacCtrl to mean the physical Control key. Writing an explicit mac binding removes the ambiguity and makes the tested key the shipped key.
Option (Alt) combined with a letter types an accented or special character on macOS keyboards — Option+E starts an accent, Option+2 types ™. Browsers generally still see Alt+Shift+<letter> as a shortcut because Shift changes the composed character, but Alt+<letter> alone is unreliable, particularly in Safari. This is another reason Alt+Shift is the safest family.
The display strings differ: Chrome’s getAll() returns symbols such as ⌥⇧S on macOS; Firefox returns Alt+Shift+S. Show whatever the API returns rather than normalising it — it matches what the browser itself shows on its shortcuts page.
Designing the shortcut experience for the weakest engine
Because Safari offers the least — no programmatic editing, no change events, no global scope, and a less discoverable editing surface — it is the right engine to design the baseline experience for. Everything Firefox adds on top can then be an enhancement rather than a dependency.
In practice that baseline has four parts. The popup must be reachable by _execute_action, because that is the one keyboard entry point every engine supports. Every page action worth a shortcut must also be reachable from the popup, so a user whose shortcut is unbound or swallowed has a keyboard path that still works. The options page must show the effective bindings read from getAll(), never a hard-coded list. And it must tell the user where to change them, in words that match their browser.
1function whereToEdit() {
2 if (globalThis.browser?.commands?.update) return "Change them below.";
3 if (navigator.userAgent.includes("Firefox")) return "Change them in Add-ons → gear menu → Manage Extension Shortcuts.";
4 if (globalThis.chrome?.sidePanel || navigator.userAgentData) return "Change them at chrome://extensions/shortcuts.";
5 return "Change them in your browser's extension settings.";
6}
Execution context: the options page. This is one of the rare places where naming the browser is justified — the instruction is about the browser’s own UI, not about API capability — but the first check is still a capability probe, so an engine that gains commands.update gets the inline editor automatically.
On top of that baseline, Firefox users get an inline editor with live validation, and Chrome users get a global-scope option for the one or two commands where it makes sense. Neither is required for the extension to be fully usable from the keyboard.
Cross-browser variation
- Chrome / Edge: four suggested keys,
_execute_actionand_execute_side_panel, read-only bindings, user editing at the shortcuts page, and optional global scope forCtrl+Shift+0–9. - Firefox: four suggested keys,
_execute_actionand_execute_sidebar_action, programmaticupdate/reset,onChanged, strict key-name validation, no global shortcuts. - Safari:
_execute_actionand ordinary commands work; no side panel, no programmatic editing, no global scope.Option-based combinations are the most likely to be swallowed by macOS text input. - All three: a command is a user gesture for
activeTab. That makes keyboard commands a first-class way to trigger page actions without host permissions.
Verification
- Run
web-ext lint --source-dir dist/firefoxand confirm nosuggested_keywarnings. - Install each build and list the effective bindings:
1(await (globalThis.browser ?? chrome).commands.getAll()).map((c) => `${c.name}: ${c.shortcut || "—"}`);
2// Firefox → ["_execute_action: Alt+Shift+R", "save-page: Alt+Shift+S", "_execute_sidebar_action: Alt+Shift+P"]
Execution context: the background console of the browser under test. A — for a command you suggested means that engine refused or conflicted the key.
- On macOS, press each shortcut in Safari with a text field focused and confirm the command fires rather than a character being typed.
- Confirm the options page shows an inline editor on Firefox and a link elsewhere.
FAQ
Does Firefox support "global": true?
No. Global commands are Chrome-only. Omit the key in the Firefox target rather than relying on it being ignored.
Why does my shortcut work in Chrome but not Firefox?
Most often a punctuation key written as a character, or a reserved command name Firefox does not know. web-ext lint finds both.
Can I detect whether a shortcut conflicted?
Only indirectly — an empty shortcut in getAll() after install means your suggestion was not applied. There is no event or error at install time.
Related
- The _execute_action command and the four-shortcut limit — the shared rules.
- Letting users rebind extension shortcuts — the editing UI per engine.
- Side panel support across browsers — the panels these reserved commands open.
- Keyboard shortcuts and commands — the parent guide.