Enabling and Disabling the Toolbar Action per Tab

Grey out the toolbar button where the extension cannot work — action.enable/disable per tab, declarativeContent rules that need no host permission, and keeping state right across navigations.

Published September 18, 2026 Updated September 18, 2026 7 min read
Table of Contents

An enabled toolbar button promises that clicking it will do something. On pages where the extension cannot act — browser internals, the extension gallery, sites outside its purpose — that promise is broken, and the user finds out by clicking and getting nothing, or a popup that says “not available here”. Disabling the action per tab keeps the promise honest. In MV3 there are two ways to do it, and the one that needs no host permission is usually the better one. This guide is part of notifications, badges and the action API.

Two mechanisms

Imperative enable/disable against declarativeContentaction.enable/disable and declarativeContent rules compared on permissions needed, whether the worker must run on navigation, what conditions they can express, and cross-browser support.Propertyaction.enable / disabledeclarativeContentNeeds host permission to see URLsYes, or tabsNoWorker wakes on navigationYesNoConditionsAny logic you writeURL + CSS selector matchDefault stateEnabledDisabled until a rule matchesFirefox / SafariSupportedNot available
declarativeContent runs inside the browser on every navigation with no worker wake and no host permission — when its conditions suffice, it wins.

Step-by-step

1. Disable by default and enable where rules match (Chrome)

1{
2  "permissions": ["declarativeContent", "activeTab"],
3  "action": { "default_popup": "popup.html" }
4}
 1chrome.runtime.onInstalled.addListener(() => {
 2  chrome.action.disable();                               // off everywhere by default
 3  chrome.declarativeContent.onPageChanged.removeRules(undefined, () => {
 4    chrome.declarativeContent.onPageChanged.addRules([{
 5      conditions: [
 6        new chrome.declarativeContent.PageStateMatcher({ pageUrl: { schemes: ["https"], hostSuffix: ".example.com" } }),
 7        new chrome.declarativeContent.PageStateMatcher({ css: ["article[itemtype*='Article']"] }),
 8      ],
 9      actions: [new chrome.declarativeContent.ShowAction()],
10    }]);
11  });
12});

Execution context: the service worker, inside onInstalled — rules persist across restarts, so removeRules then addRules keeps them idempotent across updates. The browser evaluates the conditions on every navigation without waking your worker and without granting you any access to the page. The css matcher is particularly useful: “enable wherever there is an article element”, on any site, with no host permission.

2. Disable imperatively where rules cannot express the condition

When the decision depends on your own state — the user paused the extension for this site, or signed out — use action.disable(tabId).

 1async function applyActionState(tabId, url) {
 2  if (!url?.startsWith("http")) return chrome.action.disable(tabId);
 3  const { pausedOrigins = [] } = await chrome.storage.sync.get("pausedOrigins");
 4  const origin = new URL(url).origin;
 5  return pausedOrigins.includes(origin) ? chrome.action.disable(tabId) : chrome.action.enable(tabId);
 6}
 7
 8chrome.tabs.onUpdated.addListener((tabId, info, tab) => {
 9  if (info.status === "loading" && tab.url) applyActionState(tabId, tab.url);
10});

Execution context: the service worker, with the listener at the top level. Reading tab.url in onUpdated requires the tabs permission or host access for that URL — which is exactly the cost declarativeContent avoids. Where you already hold host permissions for the relevant sites, this cost is already paid.

3. Explain the disabled state in the tooltip

A greyed-out button with no explanation looks broken. Set a title for the disabled state.

1await chrome.action.disable(tabId);
2await chrome.action.setTitle({ tabId, title: chrome.i18n.getMessage("titleUnavailableHere") });
3// "Reader isn't available on this page"

Execution context: the service worker. The title is announced by screen readers for the toolbar button and shown on hover; it is the only explanation a disabled button can give.

4. Keep the popup reachable for settings when appropriate

A disabled action cannot be clicked at all, including to reach settings. If users need to change something about the disabled state — “resume on this site” — keep the action enabled and show that choice in the popup instead.

1// popup.js — paused site: offer the way out rather than a dead button
2if (state.pausedHere) {
3  main.replaceChildren(pausedMessage(), resumeButton());
4}

Execution context: the popup. The rule of thumb: disable where the extension cannot act; keep enabled with an explanation where the user has chosen for it not to act and may want to change their mind.

5. Degrade on Firefox and Safari

declarativeContent is Chrome-only. Elsewhere, fall back to the imperative path, restricted to what the extension can see.

1const api = globalThis.browser ?? globalThis.chrome;
2if (api.declarativeContent) {
3  installDeclarativeRules();
4} else {
5  api.tabs.onUpdated.addListener((tabId, info, tab) => {
6    if (info.status === "loading") applyActionState(tabId, tab.url);
7  });
8}

Execution context: the background context. On Firefox without the tabs permission tab.url is undefined for sites you have no access to, so the fallback leaves the action enabled there — a reasonable default, and far better than disabling it everywhere. The capability approach is set out in building a capability matrix for your extension.

Disable, or keep enabled and explain?A decision tree based on whether the extension cannot act on the page or has been told not to, and whether the user might want to change that from the popup.Why shouldn't the action do anything here?It cannot (restricted page)DisabledeclarativeContent or disable()Tooltip explainsno promptNot relevant (wrong kind of page)DisableCSS / URL ruleEnable where it matchesno host permissionUser paused it hereKeep enabledpopup shows "Resume"Badge: "off"state visible at a glance
A disabled button cannot be clicked — so never disable where the popup is the way to undo the state.

Why declarativeContent is worth the Chrome-only cost

It is tempting to write the imperative version once and use it everywhere, since it works on all three engines. On Chrome, that choice has a real price. To decide per page, the worker must see every navigation’s URL, which means holding the tabs permission or broad host access — both of which show up in the install prompt and in review. And it must wake on every navigation in every tab, which on a busy browser is hundreds of worker starts a day for a feature that is mostly deciding to do nothing.

declarativeContent moves the decision into the browser. The extension declares the condition once; the browser evaluates it on each navigation using information it already has; your code never runs and never sees the URL. For the common case — “enable on these sites” or “enable on pages that contain this element” — that is strictly better on every axis Chrome users care about.

The pattern that captures both is a small adapter: declarative rules where supported, imperative checks elsewhere, and the same tooltip text in both. The Chrome build gets the lighter permission set; the Firefox and Safari builds get working behaviour within what those engines offer.

Worker wakes per day to manage action stateService worker starts attributable to action enable/disable logic for a heavy browsing day, comparing the imperative approach with declarativeContent.Imperative, onUpdated every tab340 wakes/dayImperative, URL-filtered listener90 wakes/daydeclarativeContent rules0 wakes/dayevaluated by the browser
The declarative rules cost nothing per navigation — the imperative listener costs a wake each time.

Cross-browser variation

  • Chrome / Edge: chrome.action.enable/disable(tabId) and chrome.declarativeContent with PageStateMatcher (URL and CSS conditions), ShowAction and SetIcon. Rules persist across restarts.
  • Firefox: browser.action.enable/disable per tab; no declarativeContent. Firefox also offers page_action-style behaviour through browser.pageAction in MV2 contexts, but MV3 extensions use the action with per-tab state.
  • Safari: per-tab enable/disable is supported; no declarativeContent. Safari greys out disabled extension buttons consistently with its own toolbar styling.
  • All three: a disabled action suppresses both the popup and action.onClicked. Keyboard commands mapped to _execute_action are also suppressed on that tab.

Verification

  1. On Chrome, visit a matching and a non-matching page and confirm the button’s state switches without the service worker starting — watch chrome://extensions for the worker staying inactive.
  2. Confirm the effective state for a tab:
1await chrome.action.isEnabled(tabId);
2// false

Execution context: the service worker console. isEnabled accounts for both per-tab and global state, so it answers exactly what the user will see.

  1. Pause a site, reload it, and confirm the button stays enabled with an “off” badge and a resume option in the popup.
  2. On Firefox, confirm the fallback path enables the action on matching sites and leaves it enabled elsewhere.

FAQ

Does declarativeContent need the tabs permission?

No — that is its main advantage. It needs only declarativeContent, which produces no install warning.

Can a CSS condition look inside iframes?

No; PageStateMatcher CSS conditions evaluate against the top-level document only.

Why does my action stay disabled after an update?

action.disable() with no tabId disables globally and persists. If a new version expects it enabled by default, call action.enable() in onInstalled before installing rules.

Should I hide the action instead of disabling it?

Extensions cannot hide their toolbar button — pinning is the user’s choice. Disabling is the strongest signal available, and it keeps the button in the same place so users do not wonder where it went.

Other UI/UX Patterns & Interactive Components Resources