Building a Side Panel UI in MV3

Step-by-step guide to wiring chrome.sidePanel in Manifest V3: manifest setup, default HTML, openPanelOnActionClick, per-tab enable/disable with setOptions, and messaging the service worker.

Published June 19, 2026 Updated June 19, 2026 9 min read
Table of Contents

Popups close the moment the user clicks away. For tools that need to stay visible while the user browses — a reading assistant, a research sidebar, a debugging overlay — a popup simply does not work. The chrome.sidePanel API solves this by attaching a persistent extension page to the browser’s side panel, which remains open across page navigations and tab switches. This guide is part of the Side Panel & DevTools Interfaces collection inside UI/UX Patterns & Interactive Components.

Compatibility note up front: chrome.sidePanel is a Chrome 114+ exclusive API. Firefox uses a different surface (browser.sidebarAction) with an incompatible interface. Safari has no equivalent surface at all. Everything in this guide applies only to Chrome and Chromium-based browsers that have shipped chrome.sidePanel.

Root cause: why a popup can’t do this

In Manifest V3, the popup is an ephemeral extension page — it mounts when the action icon is clicked and destroys itself when focus leaves it. There is no way to keep it alive. The service worker that backs the extension is also ephemeral; it spins down after roughly 30 seconds of inactivity. Neither context is designed for persistent UI.

The side panel is different. It is an extension page (HTML + JS running in the extension’s origin) that Chrome hosts in a dedicated sidebar column. Chrome manages its lifecycle independently of focus events. Because it is an extension page rather than the service worker, it has access to the full DOM and standard web APIs, and it communicates with the service worker and content scripts via the normal message-passing system. The service worker’s role is narrow: it registers the panel’s behavior and responds to messages, but the panel renders and runs entirely in its own page context.

Step-by-step solution

Step 1 — Declare the side panel in the manifest

Open manifest.json and add two things: a side_panel key that points to your default HTML file, and "sidePanel" in the permissions array.

 1{
 2  "manifest_version": 3,
 3  "name": "My Side Panel Extension",
 4  "version": "1.0",
 5  "permissions": ["sidePanel", "tabs"],
 6  "background": {
 7    "service_worker": "background.js"
 8  },
 9  "action": {
10    "default_title": "Open panel"
11  },
12  "side_panel": {
13    "default_path": "panel.html"
14  }
15}

Execution context: This is a static JSON declaration processed by Chrome at install/update time. The "sidePanel" permission gates all chrome.sidePanel.* calls in your service worker and any extension page. The "tabs" permission is required if you intend to call setOptions with a specific tabId. Firefox ignores the side_panel key entirely; Safari ignores it too.

Step 2 — Create the panel HTML page

Create panel.html at the root of your extension (matching default_path). This file is the document Chrome loads into the side panel column.

 1<!DOCTYPE html>
 2<html lang="en">
 3<head>
 4  <meta charset="UTF-8" />
 5  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 6  <title>Side Panel</title>
 7  <link rel="stylesheet" href="panel.css" />
 8</head>
 9<body>
10  <h1>Side Panel</h1>
11  <div id="content">Ready.</div>
12  <button id="send-btn">Send message to worker</button>
13  <script src="panel.js"></script>
14</body>
15</html>

Execution context: panel.html runs as an extension page in the extension’s origin (chrome-extension://<id>/panel.html). It has access to chrome.* APIs permitted for extension pages (including chrome.runtime, chrome.storage, chrome.tabs). It does not share a JS heap with the service worker or with content scripts. It persists across tab navigations in the same window as long as Chrome keeps the panel open.

Step 3 — Enable open-on-action-click in the service worker

In background.js, call chrome.sidePanel.setPanelBehavior inside the onInstalled listener so that clicking the action icon automatically opens the side panel instead of a popup.

1// background.js
2chrome.runtime.onInstalled.addListener(() => {
3  chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true })
4    .catch(err => console.error('setPanelBehavior failed:', err));
5});

Execution context: Runs in the MV3 service worker. chrome.sidePanel is available here because "sidePanel" is declared in permissions. If you also declared a "default_popup" in the "action" key, the popup takes precedence — remove it if you want the side panel to open on click. Firefox: browser.sidebarAction has its own toggle mechanism; this call does nothing in Firefox. Safari: not applicable.

Step 4 — Enable or disable the panel per tab

Use chrome.sidePanel.setOptions with a tabId to control whether the panel is available on each tab. This is useful when your extension should only be active on certain domains.

 1// background.js
 2chrome.tabs.onActivated.addListener(({ tabId }) => {
 3  chrome.tabs.get(tabId, (tab) => {
 4    const enabled = tab.url?.startsWith('https://') ?? false;
 5    chrome.sidePanel.setOptions({
 6      tabId,
 7      enabled,
 8      path: 'panel.html'
 9    }).catch(err => console.error('setOptions failed:', err));
10  });
11});
12
13chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
14  if (changeInfo.status !== 'complete') return;
15  const enabled = tab.url?.startsWith('https://') ?? false;
16  chrome.sidePanel.setOptions({
17    tabId,
18    enabled,
19    path: 'panel.html'
20  }).catch(err => console.error('setOptions failed:', err));
21});

Execution context: Service worker. The tabs.onActivated event fires when the user switches tabs; tabs.onUpdated fires when the current tab navigates. Both require the "tabs" permission to read tab.url. If enabled is false, the panel icon is grayed out for that tab and the panel cannot be opened. Chrome 114+ only — this entire block is a no-op in Firefox and Safari.

Step 5 — Send messages from the panel to the service worker

In panel.js, use chrome.runtime.sendMessage to send data to the service worker on demand. In background.js, register a listener with chrome.runtime.onMessage.

 1// panel.js (runs in panel.html)
 2document.getElementById('send-btn').addEventListener('click', () => {
 3  chrome.runtime.sendMessage(
 4    { type: 'PANEL_ACTION', payload: { timestamp: Date.now() } },
 5    (response) => {
 6      if (chrome.runtime.lastError) {
 7        console.error(chrome.runtime.lastError.message);
 8        return;
 9      }
10      document.getElementById('content').textContent = response.status;
11    }
12  );
13});
1// background.js
2chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
3  if (message.type === 'PANEL_ACTION') {
4    console.log('Panel sent action at', message.payload.timestamp);
5    sendResponse({ status: 'Acknowledged by worker' });
6    return true; // keep message channel open for async sendResponse
7  }
8});

Execution context: chrome.runtime.sendMessage runs in the extension page (panel.js). The listener runs in the service worker (background.js). The service worker may have been suspended since the panel last messaged it — Chrome will wake it automatically when a message arrives. Returning true from the listener keeps the response channel open for async use. In Firefox with browser.sidebarAction, message-passing works the same way via browser.runtime.sendMessage. Safari does not support the side panel surface, but message-passing between extension pages and background scripts follows the same pattern where supported.

Step 6 — Open the panel imperatively

For cases where you want to open the panel from code rather than waiting for the user to click the action icon, use chrome.sidePanel.open. This call requires either a user gesture in the calling context or that the extension already has the panel enabled for the tab.

1// background.js — open panel when a specific tab finishes loading
2chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
3  if (changeInfo.status === 'complete') {
4    chrome.sidePanel.open({ tabId })
5      .catch(err => console.error('sidePanel.open failed:', err));
6  }
7});

Execution context: Service worker. chrome.sidePanel.open was added in Chrome 116. If you target Chrome 114–115, this method does not exist — use setPanelBehavior and let the user click the icon instead. Chrome 116+ is required for imperative opening. Always wrap in .catch because the call throws if the user has manually closed the panel or if the tab is not in the foreground window.

Cross-browser variation

  • Chrome 114+: Full chrome.sidePanel API support. setPanelBehavior, setOptions, and open are all available (note open requires Chrome 116+). This is the only browser where everything in this guide works.
  • Chrome 113 and earlier: chrome.sidePanel does not exist. Feature-detect with if (chrome.sidePanel) before any call.
  • Firefox: Uses browser.sidebarAction — a different API with different method names (open, close, toggle, setPanel, setIcon, setTitle). There is no setOptions per-tab enable/disable equivalent. Manifest declaration uses "sidebar_action" instead of "side_panel". Code must be branched or abstracted for cross-browser support.
  • Safari: No side panel or sidebar extension surface exists. The Web Extensions API for Safari does not include browser.sidebarAction or chrome.sidePanel.

Verification

  1. Load the extension unpacked at chrome://extensions with Developer mode enabled.
  2. Click the extension’s action icon in the toolbar — the side panel should open on the right side of the browser window.
  3. Navigate to a new URL in the same tab. The panel should remain open and continue showing content without reloading (unless your panel.js responds to chrome.tabs.onUpdated messages to refresh its own view).
  4. Open the side panel’s DevTools: right-click anywhere inside the panel and choose “Inspect”. This opens a DevTools window scoped to panel.html. Confirm that console.log output from panel.js appears here, not in the service worker’s console.
  5. Click the “Send message to worker” button. In the panel’s DevTools console you should see the response "Acknowledged by worker" appear in the #content div. In the service worker’s console (accessible via the “Service Worker” link on chrome://extensions), you should see the 'Panel sent action at ...' log line.
  6. Switch to a non-HTTPS tab (e.g., chrome://newtab). If you implemented Step 4, the panel icon should be grayed out, indicating the panel is disabled for that tab.

FAQ

Can I open the panel from a content script?

Not directly. Content scripts cannot call chrome.sidePanel — that API is restricted to service workers and extension pages. Send a message from your content script to the service worker using chrome.runtime.sendMessage, then call chrome.sidePanel.open({ tabId: sender.tab.id }) inside the service worker’s onMessage handler. Note that sidePanel.open still requires a user gesture context in some scenarios; triggering it in response to a message from a user action (like a button click in the content script) generally satisfies this requirement.

Does the panel share state with the popup?

No. The side panel and the popup (if you have one) are separate extension pages with separate JS heaps. They do not share variables or DOM state. Use chrome.storage (see Chrome Storage API & Sync) or message-passing (see Message Passing Architecture) to synchronize state between them. If openPanelOnActionClick is true, the popup is suppressed and only the panel opens — you cannot have both active simultaneously.

How do I close the panel programmatically?

There is no chrome.sidePanel.close() method as of Chrome 116. You cannot close the side panel from extension code — only the user can close it via the browser UI. The best workaround is to render a “close” button inside panel.html that navigates the panel to a blank or minimal page, giving the appearance of closure, while the panel container itself remains in Chrome’s sidebar. Watch the Chrome issue tracker for a future close() addition.

What happens if the user has the panel open and my service worker restarts?

Because the panel is an extension page (not the service worker), it continues running normally when the worker suspends and restarts. Any onMessage listeners you registered in the worker are re-registered when the worker wakes. However, any in-memory state the worker held is lost. Use chrome.storage.session for short-lived state that needs to survive worker restarts within a browser session.

Other UI/UX Patterns & Interactive Components Resources