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.
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
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.
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.
Cross-browser variation
- Chrome / Edge:
chrome.action.enable/disable(tabId)andchrome.declarativeContentwithPageStateMatcher(URL and CSS conditions),ShowActionandSetIcon. Rules persist across restarts. - Firefox:
browser.action.enable/disableper tab; nodeclarativeContent. Firefox also offerspage_action-style behaviour throughbrowser.pageActionin MV2 contexts, but MV3 extensions use the action with per-tab state. - Safari: per-tab
enable/disableis supported; nodeclarativeContent. 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_actionare also suppressed on that tab.
Verification
- On Chrome, visit a matching and a non-matching page and confirm the button’s state switches without the service worker starting — watch
chrome://extensionsfor the worker staying inactive. - 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.
- Pause a site, reload it, and confirm the button stays enabled with an “off” badge and a resume option in the popup.
- 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.
Related
- Handling restricted URLs and tab permissions — the pages that should always be disabled.
- Badge text, colour and count patterns — signalling state alongside enablement.
- Scoping rules to a single tab — the per-tab pause this pairs with.
- Notifications, badges and the action API — the parent guide.