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.

Published July 25, 2026 Updated July 25, 2026 8 min read
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.

Persistent side surfaces by engineAPI, manifest key, per-tab control, programmatic opening and gesture requirements across the three browsers.AspectChrome 120+Firefox 121+Safari 17+APIchrome.sidePanelsidebarActionNoneManifest keyside_panelsidebar_actionPer-tab configurationsetOptions({tabId})Per windowOpen programmaticallyNeeds a gesturesidebarAction.openwindows.createSurvives navigationYesYesYes (window)User can close itYesYesYes
Only the document is shared — every row in this table is part of the registration layer.

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.

One document behind three registration pathsThe same panel HTML is loaded by the Chrome side panel, the Firefox sidebar and a Safari popup window, with only the host layer differing.panel/panel.htmlone document, no branchingloaded by whichever host the engine provideschrome.sidePanelper tab, dockedsidebarActionper window, dockedwindows.createfloating popup
Keep every engine check in the host layer and the document never needs to know where it is running.

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.

The panel's lifetime against the tab it describesThe panel loads for one tab, the user navigates and switches tabs, and the panel must re-derive its context rather than trusting what it rendered.panel openedpanel closedLoads for tab Areads its stateUser navigatespanel unchangedonActivated handledre-derives contextWorker evictedpanel keeps runningstale window — subscribe to tab eventspanel must tolerate an absent worker
Every navigation is a moment the panel's contents can silently become wrong.
 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.sidePanel with the sidePanel permission; open() requires a user gesture from Chrome 116. Per-tab setOptions is the distinguishing feature.
  • Firefox 121+: sidebarAction declared in the manifest, opened with sidebarAction.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

  1. Open the panel in each engine from a toolbar click and confirm the same document renders in all three.
  2. In Chrome, navigate to an unsupported origin and confirm the panel is disabled for that tab and still available in others.
  3. Navigate the underlying tab several times and confirm the panel updates rather than showing the original page’s data.
  4. 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.

Other UI/UX Patterns & Interactive Components Resources