Moving from browserAction and pageAction to action
Migrate chrome.browserAction and chrome.pageAction to MV3's chrome.action: manifest keys, renamed methods, recreating page-action show/hide with enable and disable, and declarativeContent rules.
Table of Contents
Two MV2 APIs became one in MV3. chrome.browserAction — the always-visible toolbar button — and chrome.pageAction — the button that appeared only on relevant pages — merged into chrome.action. For a browser action the migration is almost a find-and-replace. For a page action it is a behaviour change: there is no longer a separate “show only on matching pages” mode, and the extension must recreate it with action.disable(), action.enable() and, ideally, declarativeContent rules. Getting it wrong produces an icon that is permanently greyed out or permanently clickable. This guide belongs to Manifest V2 to V3 migration.
What changed and why
MV2’s two action types confused users, who could not tell why some extension icons sat in the address bar and others in the toolbar, and Chrome had long since moved every action into the toolbar and extensions menu anyway. MV3 makes that official: one action key, one chrome.action namespace, one icon per extension in the toolbar. The page-action concept survives as state: an action can be disabled for some tabs and enabled for others, and a disabled action is greyed out and does not open its popup or fire onClicked. The default is enabled everywhere, which is the opposite of MV2’s page action — the root of most migration bugs here.
Step-by-step: migrate either kind
1. Rename the manifest key
1// MV2
2"browser_action": { "default_popup": "popup.html", "default_icon": "icon.png", "default_title": "Clip" }
3// or
4"page_action": { "default_popup": "popup.html", "default_icon": "icon.png", "show_matches": ["https://*.example.com/*"] }
5
6// MV3 (either case)
7"action": { "default_popup": "popup.html", "default_icon": { "16": "icons/16.png", "32": "icons/32.png" }, "default_title": "Clip" }
Execution context: the manifest. show_matches and hide_matches from Firefox’s page action have no MV3 equivalent in Chrome and are ignored. Provide icons as a size map so the toolbar renders crisply at every scale, as described in icon sizes for the manifest and action.
2. Rename the API calls
1// mapping applied across the codebase
2chrome.browserAction.setBadgeText → chrome.action.setBadgeText
3chrome.browserAction.setIcon → chrome.action.setIcon
4chrome.browserAction.setPopup → chrome.action.setPopup
5chrome.browserAction.onClicked → chrome.action.onClicked
6chrome.pageAction.show(tabId) → chrome.action.enable(tabId)
7chrome.pageAction.hide(tabId) → chrome.action.disable(tabId)
Execution context: the service worker and extension pages. Most methods keep their signatures, and all return promises in MV3. chrome.action adds getUserSettings() (whether the user pinned the action) and openPopup(), which have no MV2 equivalent. A codemod or a careful regex replace handles the rename; review each pageAction call by hand because the semantics changed.
3. Recreate page-action behaviour with declarativeContent
The cleanest replacement for “show only on matching pages” is a set of declarativeContent rules, which the browser evaluates without waking your worker.
1{ "permissions": ["declarativeContent"] }
1// sw.js
2chrome.runtime.onInstalled.addListener(async () => {
3 await chrome.action.disable(); // default: disabled on every tab
4 await chrome.declarativeContent.onPageChanged.removeRules();
5 await chrome.declarativeContent.onPageChanged.addRules([{
6 conditions: [
7 new chrome.declarativeContent.PageStateMatcher({ pageUrl: { hostSuffix: "example.com", schemes: ["https"] } }),
8 new chrome.declarativeContent.PageStateMatcher({ css: ["article[data-recipe]"] }),
9 ],
10 actions: [new chrome.declarativeContent.ShowAction()],
11 }]);
12});
Execution context: the service worker, during onInstalled because rules persist across restarts. action.disable() with no tab id sets the global default; ShowAction enables the action on tabs that match any condition. The css matcher can test whether the page contains an element — useful for “only on recipe pages” — and needs no host permission. declarativeContent is Chrome-only; see step 5 for other engines.
4. Use enable and disable from code for dynamic conditions
When the condition depends on something rules cannot express — the user’s settings, data fetched from your API — compute it and set the state per tab.
1chrome.tabs.onUpdated.addListener(async (tabId, change, tab) => {
2 if (change.status !== "complete" || !tab.url) return;
3 const supported = await isSupportedSite(tab.url); // reads storage, maybe the API
4 await (supported ? chrome.action.enable(tabId) : chrome.action.disable(tabId));
5});
Execution context: the service worker. Reading tab.url requires host permission for the tab or the tabs permission, which is a cost declarativeContent avoids. This listener wakes the worker on every page load in every tab, so prefer rules where they suffice.
5. Decide whether to disable at all
Disabling the action greys it out, but it stays in the toolbar — users who pinned it see a dead icon on most sites, which looks broken. Many migrations do better to leave the action enabled everywhere and make the popup explain what it can do on the current page, as in designing a degraded mode. Disable only when clicking would be truly meaningless.
1// Alternative: keep enabled, change the title to explain
2await chrome.action.setTitle({ tabId, title: supported ? "Save recipe" : "Open a recipe page to save it" });
Execution context: the service worker. A per-tab title appears as the tooltip, giving a disabled-looking state without a disabled icon.
Common mistakes
- Forgetting the global disable. A former page action migrated without
action.disable()is enabled on every tab, so users see a clickable icon everywhere and a popup that does nothing on most sites. - Calling disable on every install event without removing old rules.
declarativeContentrules accumulate across updates unless you callremoveRules()first. - Relying on
show_matchesin Chrome. It is a Firefox page-action key; Chrome ignores it silently. - Expecting a hidden icon. Disabled actions are greyed, not hidden. Users control visibility by pinning; you control only enabled state.
- Waking the worker on every navigation.
tabs.onUpdatedhandlers for enable/disable run constantly. UsedeclarativeContentwhere the condition is a URL or a CSS selector.
Cross-browser variation
- Chrome / Edge:
chrome.actionplusdeclarativeContentfor rule-based enabling. - Firefox: supports
actionin MV3 and still supportspage_actionas a separate address-bar button, includingshow_matches. There is nodeclarativeContent; usetabs.onUpdatedwith enable/disable, or keep a Firefox-specificpage_actiondeclared in a Firefox-only manifest. - Safari: supports
action,enableanddisable; nodeclarativeContent. Safari shows disabled actions greyed in the toolbar like Chrome.
Verification
- Load the MV3 build and open a non-matching page: the icon should be greyed (if disabled by default) and clicking it should do nothing.
- Open a matching page: the icon should be active and open the popup.
- In the service worker console, run
await chrome.action.isEnabled(tabId)for both tabs and confirmfalseandtrue. - Reload the extension and confirm
declarativeContentrules are not duplicated:chrome.declarativeContent.onPageChanged.getRules(console.log)returns one rule set.
FAQ
Can an MV3 action live in the address bar like a page action?
Not in Chrome. All actions live in the toolbar or the extensions menu. Firefox can still show a page_action in its address bar.
Does disabling the action also disable its keyboard shortcut?
The _execute_action command respects the disabled state — it does nothing on tabs where the action is disabled. Custom commands are unaffected.
Can I hide the action entirely?
No. Pinning is the user’s choice. You can only enable, disable, and change the icon, title and badge.
Do badge text and icon changes still work per tab?
Yes. Every chrome.action setter accepts an optional tabId, exactly as the MV2 setters did, and per-tab values override the global default until the tab navigates or closes. That makes per-tab state the natural replacement for page-action visibility: a disabled action with a per-tab title explaining why, rather than an icon that appears and disappears.
What about the action’s context menu?
Items created with contexts: ["action"] appear when the user right-clicks the toolbar icon. They work whether the action is enabled or disabled on the current tab, which makes them a good home for settings and help links that should always be reachable.
Related
- Enabling and disabling the toolbar action per tab — per-tab state in detail.
- Setting the popup per tab with action.setPopup — varying what the click opens.
- Replacing tabs.executeScript with chrome.scripting — the other common rename-plus-behaviour change.
- Manifest V2 to V3 migration — the parent topic.