Side Panel Support Across Browsers
A reference for persistent extension UI surfaces across engines: chrome.sidePanel, Firefox sidebarAction and the Safari fallback, with one panel document that works in all three.
Table of Contents
A persistent side surface is the natural home for anything the user needs while they browse, and it is the extension UI with the least agreement between engines. Chrome has chrome.sidePanel, Firefox has the older sidebarAction, and Safari has neither. The shapes are close enough that one document can serve all three, and different enough that the registration layer cannot be shared. This page is part of Side Panel & DevTools Interfaces and is the reference for what each engine offers.
Root cause: two APIs and one absence
chrome.sidePanel arrived with MV3 and is per-tab configurable. sidebarAction predates it, is declared in the manifest, and is per-window rather than per-tab. Safari has no docked extension surface at all, so the equivalent is a window the extension opens. All three can display the same HTML document — which is where the portability lives.
Declare both keys, per target
The two manifest keys can coexist in a single file, and each engine ignores the one it does not implement — but since Chrome rejects the Firefox extension id anyway, the per-target patch already exists and is the tidier place for this.
1// manifest.chrome.json
2{
3 "permissions": ["sidePanel"],
4 "side_panel": { "default_path": "panel/panel.html" }
5}
6
7// manifest.firefox.json
8{
9 "sidebar_action": {
10 "default_panel": "panel/panel.html",
11 "default_title": "Review Queue",
12 "default_icon": { "32": "icons/32.png" },
13 "open_at_install": false
14 }
15}
Execution context: Merged into the built manifest per target. The same panel/panel.html serves both, which is the point — the document is written once and neither key changes it. open_at_install: false is worth setting explicitly in Firefox: the default behaviour of opening the sidebar on install is startling, and users who have not asked for a panel read it as intrusive.
One document, three registrations
The panel itself is an ordinary extension page. Only the code that opens and configures it needs to branch, and it should do so once at load rather than at every call site.
1// panel-host.js — the only file that knows which engine it is on
2const api = typeof browser !== "undefined" ? browser : chrome;
3
4export const PANEL = {
5 kind: api.sidePanel ? "sidePanel" : api.sidebarAction ? "sidebar" : "window",
6
7 async openFromGesture() {
8 if (this.kind === "sidePanel") {
9 const [tab] = await api.tabs.query({ active: true, lastFocusedWindow: true });
10 return api.sidePanel.open({ tabId: tab.id });
11 }
12 if (this.kind === "sidebar") return api.sidebarAction.open();
13 // Safari and anything else: a small window is the closest equivalent.
14 return api.windows.create({
15 url: api.runtime.getURL("panel/panel.html"),
16 type: "popup", width: 420, height: 720,
17 });
18 },
19};
Execution context: Extension service worker, called from a user gesture such as chrome.action.onClicked. Resolving kind once at module load keeps the branching out of the rest of the codebase, and the gesture requirement is real in both Chrome and Firefox — a call made after an await has already lost it, exactly as with optional permissions.
Per-tab enablement is Chrome-only
chrome.sidePanel.setOptions with a tabId is genuinely useful — it lets the panel apply only where it makes sense — and it has no equivalent elsewhere. Treat it as an enhancement rather than as the basis of the feature.
1// background.js — narrow the panel to supported origins where the engine allows
2chrome.tabs?.onUpdated.addListener((tabId, changeInfo, tab) => {
3 if (!chrome.sidePanel || changeInfo.status !== "complete" || !tab.url) return;
4
5 const supported = /^https:\/\/(app|docs)\.example\.com\//.test(tab.url);
6 void chrome.sidePanel.setOptions({
7 tabId,
8 path: "panel/panel.html",
9 enabled: supported,
10 });
11});
Execution context: Extension service worker with the sidePanel permission and access to the tab’s URL. Omitting tabId would set the global default and change every tab at once, which is the mistake this API invites. In Firefox the panel is per window and always available once declared, so the equivalent behaviour has to live inside the document — it renders an explanatory empty state on unsupported sites rather than being disabled.
The document has to survive navigation
Whichever host opens it, the panel outlives the pages beside it. That is its value and its main correctness hazard: it can be showing information about a tab the user left several navigations ago.
1// panel/panel.js — re-derive on every tab change, in every engine
2const api = typeof browser !== "undefined" ? browser : chrome;
3
4async function render() {
5 const [tab] = await api.tabs.query({ active: true, lastFocusedWindow: true });
6 const state = tab?.id ? await api.runtime.sendMessage({ type: "PANEL_STATE", tabId: tab.id }) : null;
7 paint(state);
8}
9
10api.tabs.onActivated.addListener(() => { void render(); });
11api.tabs.onUpdated.addListener((_id, changeInfo) => { if (changeInfo.status === "complete") void render(); });
12document.addEventListener("DOMContentLoaded", () => { void render(); });
Execution context: Panel document renderer, in whichever host opened it. Subscribing to both onActivated and onUpdated covers tab switching and in-tab navigation, which are different events with the same consequence for the panel. Because the worker may be evicted, sendMessage can pay a cold start — render a skeleton first rather than leaving the panel blank while it resolves.
Handling the engine that has nothing
Safari’s absence is the case worth designing for explicitly, because the alternatives are a floating window or no feature at all — and which is right depends on what the panel does. A panel that mirrors the current page benefits little from floating alongside it; a panel that collects items across a browsing session works nearly as well in a window.
1// panel-host.js — say what the user gets, rather than failing quietly
2export function panelAvailability() {
3 if (PANEL.kind === "sidePanel") return { available: true, docked: true };
4 if (PANEL.kind === "sidebar") return { available: true, docked: true };
5 return {
6 available: true,
7 docked: false,
8 note: "This browser has no docked panel, so the queue opens in its own window.",
9 };
10}
Execution context: Extension service worker, read by whichever surface renders the button that opens the panel. Returning a note rather than a boolean lets the popup explain the difference in one line, which is much better than a button that behaves unexpectedly. The one thing to avoid is hiding the feature entirely on Safari — the window fallback preserves the persistence that made the panel worth building.
Keeping the panel and the worker loosely coupled
Because the panel outlives worker evictions, every exchange between them has to tolerate a cold start, and the panel should never hold the only copy of anything. Treat the panel as a renderer over stored state: it asks the worker for a snapshot, subscribes to chrome.storage.onChanged for updates, and re-requests after any failed message rather than retrying against a worker that may not exist yet. That shape also makes the three hosts behave identically, since none of them changes how storage or messaging works.
Cross-browser variation
- Chrome 114+:
chrome.sidePanelwith thesidePanelpermission;open()requires a user gesture from Chrome 116. Per-tabsetOptionsis the distinguishing feature. - Firefox 121+:
sidebarActiondeclared in the manifest, opened withsidebarAction.open()from a gesture, configured per window rather than per tab. - Safari 17+: no docked surface. A popup-type window is the closest equivalent and behaves well, but it floats rather than docking.
- All engines: the panel document outlives page navigations, so it must derive its context from tab events rather than from what it rendered when it opened.
Verification
- Open the panel in each engine from a toolbar click and confirm the same document renders in all three.
- In Chrome, navigate to an unsupported origin and confirm the panel is disabled for that tab and still available in others.
- Navigate the underlying tab several times and confirm the panel updates rather than showing the original page’s data.
- Stop the service worker while the panel is open and confirm it degrades to a skeleton rather than throwing.
FAQ
Can I open the side panel without a user gesture?
Not in Chrome from version 116 onward, and Firefox also expects a gesture. Design around it: a badge or a notification invites the user to open it themselves.
Is sidebarAction deprecated in favour of sidePanel?
They are separate APIs from separate vendors, not versions of one another. Firefox implements sidebarAction and not sidePanel, so both remain current in their own engines.
What should Safari users get?
A popup-type window loading the same document. It floats rather than docking, which is a real difference, but it preserves the persistence that makes the feature worth having.
Does the panel keep the service worker alive?
No. The panel is an independent document, and the worker is evicted on its own schedule — which is why the panel must handle a cold start on every message.
Related
- Building a side panel UI in MV3 — the implementation in full.
- Side Panel & DevTools Interfaces — the guide this page belongs to.
- Popup Interface Design — the surface a panel replaces when persistence matters.
- Cross-browser API compatibility — the wider divergence picture.