Setting the Popup per Tab with action.setPopup
Show different popups on different pages in MV3 with chrome.action.setPopup: per-tab and global popups, clearing to fire onClicked, updating on navigation, query strings for context, and Firefox and Safari behaviour.
Table of Contents
On a supported site, clicking the toolbar button should open a full popup with that site’s tools; on every other site, a short “Readable works on these sites” message — or no popup at all, just an action. A single default_popup in the manifest cannot express that. chrome.action.setPopup changes which HTML file the popup shows, globally or for one tab, and setting it to an empty string removes the popup so that clicks fire action.onClicked instead. Used well, it lets one toolbar button adapt to context; used carelessly, it leaves tabs showing the wrong popup after navigation. This guide shows the reliable pattern. It belongs to extension popup architecture.
How popup settings resolve
Each tab has an optional per-tab popup; if none is set, the global popup applies; if the global popup has never been set, the manifest’s action.default_popup applies. chrome.action.setPopup({ popup, tabId }) sets the per-tab value, and without tabId it sets the global value. An empty string means “no popup”, in which case clicking the action fires chrome.action.onClicked with the tab. Per-tab settings are cleared when the tab is closed, and — importantly — Chrome resets per-tab action state such as badge text and popup when the tab navigates in some cases but not others, so relying on a per-tab value surviving navigation is fragile. The robust approach is to recompute the popup for a tab whenever it navigates.
Step-by-step: context-aware popups
1. Decide the popup from the URL
1// sw.js
2const SITE_POPUPS = [
3 { test: (u) => u.hostname.endsWith("github.com"), popup: "popups/github.html" },
4 { test: (u) => u.hostname.endsWith("docs.example.com"), popup: "popups/docs.html" },
5];
6
7function popupFor(url) {
8 try {
9 const u = new URL(url);
10 if (!/^https?:$/.test(u.protocol)) return "popups/unsupported.html";
11 return SITE_POPUPS.find((s) => s.test(u))?.popup ?? "popups/general.html";
12 } catch {
13 return "popups/general.html";
14 }
15}
Execution context: the service worker. Keep the mapping as data so adding a site is one line. The fallback popup for unsupported pages should explain rather than show disabled controls. Reading the URL requires host permissions for those sites or the tabs permission; without them, see step 5.
2. Apply it whenever a tab navigates or activates
1chrome.tabs.onUpdated.addListener((tabId, change, tab) => {
2 if (change.url || change.status === "loading") applyPopup(tabId, tab.url);
3});
4chrome.tabs.onActivated.addListener(async ({ tabId }) => {
5 const tab = await chrome.tabs.get(tabId);
6 applyPopup(tabId, tab.url);
7});
8
9async function applyPopup(tabId, url) {
10 if (!url) return;
11 await chrome.action.setPopup({ tabId, popup: popupFor(url) }).catch(() => {}); // tab may be gone
12}
Execution context: the service worker, listeners at the top level. Recomputing on every URL change covers full navigations and single-page-app history changes that update tab.url. Recomputing on activation covers tabs that were open before the extension started. Errors for tabs that closed in the meantime are expected and ignored.
3. Pass context through the popup URL
1await chrome.action.setPopup({ tabId, popup: `popups/github.html?repo=${encodeURIComponent(repoFrom(url))}` });
1// popups/github.js
2const repo = new URLSearchParams(location.search).get("repo");
Execution context: the service worker sets the popup; the popup page reads it. A query string lets one popup page serve many contexts without a message round trip on open — the popup knows immediately which repository it is about. Keep values small and non-sensitive; the URL is visible in the popup’s DevTools.
4. Use an empty popup to make the action a button
1// On pages where clicking should act immediately instead of showing UI
2await chrome.action.setPopup({ tabId, popup: "" });
3
4chrome.action.onClicked.addListener(async (tab) => {
5 await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ["reader-mode.js"] });
6});
Execution context: the service worker. With no popup, clicking fires onClicked with an activeTab grant for the tab, perfect for one-shot actions (“toggle reader mode on this article”). Mixing both models per tab — a popup on some sites, an instant action on others — is powerful but can confuse users; make the behaviour predictable from the icon or title, which you can also set per tab.
5. Work without reading URLs
1// With only activeTab: decide inside the popup instead of in advance
2// popups/router.html → router.js
3const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); // url available via activeTab
4location.replace(popupFor(tab.url));
Execution context: a small router popup page. If the extension has neither host permissions nor tabs, the worker cannot read URLs in onUpdated. The popup, however, receives an activeTab grant when opened, so it can read the URL itself and redirect to the right popup page instantly. This keeps permissions minimal at the cost of a tiny redirect on open.
6. Reset global state deliberately
1chrome.runtime.onInstalled.addListener(() => chrome.action.setPopup({ popup: "popups/general.html" }));
Execution context: the service worker. Global popup changes persist across restarts in some versions and not others; setting the global default explicitly on install and startup makes behaviour predictable. Prefer per-tab values for context-specific behaviour so one tab’s state never leaks into another.
7. Keep the experience consistent
Users learn what the toolbar button does. If it opens a full tool on one site and nothing visible on another, set a per-tab title explaining the difference (“Readable — click to toggle reader mode”) so the tooltip tells them before they click.
Common mistakes
- Setting the popup once per tab and assuming it sticks. Recompute on navigation.
- Global changes for tab-specific context. Other tabs inherit the wrong popup.
- Forgetting
onActivated. Tabs opened before the worker started never get a value. - Empty popup without an
onClickedhandler. Clicks do nothing. - Sensitive data in the popup URL. Keep query parameters minimal.
Cross-browser variation
- Chrome / Edge:
action.setPopupwithtabId; empty string firesonClicked. - Firefox:
browserAction/action.setPopupsupport per-tab and per-window values (windowId), which Chrome lacks; empty string behaves the same. - Safari: supports
setPopup; per-tab behaviour has had inconsistencies — recomputing on navigation is especially important.
Verification
- Open GitHub, a docs page and a news site in three tabs; confirm each opens its own popup.
- Navigate a tab from GitHub to the news site and confirm the popup switches.
- Set an empty popup on one tab and confirm the click runs the action.
- Restart the browser and confirm popups are correct on the restored tabs after activation.
FAQ
Can I open the popup programmatically after setting it?
chrome.action.openPopup() opens the popup for the current window in recent Chrome versions, subject to its own conditions. See opening a popup from the service worker.
Does getPopup tell me the effective popup?
chrome.action.getPopup({ tabId }) returns the value that would be used for that tab, following the per-tab → global → manifest order.
Is there a limit on how often I can call setPopup?
No practical limit, but avoid calling it for every onUpdated event without a URL change — status changes fire often.
How do I test per-tab popups automatically?
In an end-to-end test, open pages for each site category, then call chrome.action.getPopup({ tabId }) from the service worker for each tab and assert the expected file. Clicking the real toolbar button is not possible in most automation tools, but getPopup checks exactly what the click would open.
Does a per-tab popup apply in incognito tabs?
Yes, for an extension allowed in incognito. In split mode, the incognito instance of the worker manages its own tabs’ popups.
What if two features want different popups on the same site?
Make the site’s popup a small hub with tabs or links to each feature. One toolbar button can only open one page; a hub keeps both features reachable without fighting over setPopup.
Related
- Enabling and disabling the toolbar action per tab — the related per-tab state.
- Moving from browserAction and pageAction to action — the API’s MV2 origins.
- Detecting tab URL changes — the navigation events used here.
- Extension popup architecture — the parent topic.