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.

Published October 2, 2026 Updated October 2, 2026 7 min read
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.

Two MV2 actions become one MV3 actionbrowserAction maps directly onto action; pageAction maps onto action disabled by default and enabled per tab by code or by declarativeContent rules.browser_actionalways enabled→ actionrenameSame behaviourno extra codepage actions need statepage_actionshown on matches→ action + disable()default offenable per tabor declarativeContent
A page action is now just an action that starts disabled.

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.

MV2 action calls and their MV3 equivalentsbrowserAction and pageAction methods mapped to chrome.action, with notes on behaviour differences.MV2 callMV3Behaviour changebrowserAction.*action.*NonepageAction.showaction.enable(tabId)Icon stays in toolbarpageAction.hideaction.disable(tabId)Greyed, not hiddenshow_matchesdeclarativeContentRules, not a key—action.openPopup()New in MV3
Renames for browser actions; show and hide become enable and disable for page actions.

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.

Enabling the action without waking the workerOn install the worker disables the action globally and registers declarativeContent rules; later, as the user navigates, the browser evaluates the rules itself and enables the action on matching tabs.Service workerBrowserTabaction.disable()declarativeContent.addRulesnavigates to example.com/reciperule matchesaction enabled for this tab
After install, the browser does the matching — your worker can stay asleep.

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. declarativeContent rules accumulate across updates unless you call removeRules() first.
  • Relying on show_matches in 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.onUpdated handlers for enable/disable run constantly. Use declarativeContent where the condition is a URL or a CSS selector.

Cross-browser variation

  • Chrome / Edge: chrome.action plus declarativeContent for rule-based enabling.
  • Firefox: supports action in MV3 and still supports page_action as a separate address-bar button, including show_matches. There is no declarativeContent; use tabs.onUpdated with enable/disable, or keep a Firefox-specific page_action declared in a Firefox-only manifest.
  • Safari: supports action, enable and disable; no declarativeContent. Safari shows disabled actions greyed in the toolbar like Chrome.

Verification

  1. 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.
  2. Open a matching page: the icon should be active and open the popup.
  3. In the service worker console, run await chrome.action.isEnabled(tabId) for both tabs and confirm false and true.
  4. Reload the extension and confirm declarativeContent rules 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.

Other MV3 Architecture & Extension Lifecycle Resources