Skip to content
C.W.K.
Stream
Lesson 02 of 06 · published

Registering the Side Panel — Manifest + chrome.sidePanel

~12 min · side-panel, manifest, chrome.sidePanel, setOptions, user-gesture

Level 0Extension Curious
0 XP0/56 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Declare in the manifest. Manipulate from the service worker. Open under a user gesture. Three sentences, three lessons in one — and three real ways the API can quietly refuse to work if you skip any of them."

Step 1 — Declare in the Manifest

Two fields together register a default panel:

  • side_panel.default_path — a convenient manifest default. It is not the only way to define a panel path; chrome.sidePanel.setOptions can configure a global or per-tab path at runtime.
  • "sidePanel" in permissions — required to call chrome.sidePanel.* from the service worker. Without it, the API itself is undefined.

That alone is enough for the panel to show up in Chrome's side-panel chooser. Users can pick it manually; nothing else is wired yet.

Step 2 — Configure Behavior From the SW

The chrome.sidePanel API exposes three methods you'll actually use:

  • chrome.sidePanel.setOptions({ tabId?, path, enabled }) — set the panel HTML and enabled state, optionally per tab. Pass a tabId to target one tab; omit it for global defaults.
  • chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: boolean }) — toggle whether clicking the toolbar icon opens the panel instead of the popup. Mutually exclusive with the popup on icon click: either click opens the panel or click opens the popup.
  • chrome.sidePanel.open({ tabId? | windowId? }) — programmatically open the panel. Must be called from a user-gesture handler (action click, keyboard command, context menu) or the call rejects.

All three live in the SW. The panel itself does not configure its own visibility — that authority sits with the SW, which has the cross-tab perspective.

The User-Gesture Rule

chrome.sidePanel.open() is the strict one. Calling it from a timer, an alarm, a webRequest event, or a content-script message that did not originate from a user gesture will reject with Side panel can only be opened by a user gesture. The legitimate triggers:

  • chrome.action.onClicked — toolbar icon click. Only fires when there is no default_popup in the manifest.
  • chrome.commands.onCommand — keyboard shortcut declared in the manifest commands block.
  • chrome.contextMenus.onClicked — right-click menu item.
  • chrome.runtime.onMessage when the message came from a popup or panel responding to a real user click — Chrome treats that as a gesture-derived event.

This rule is a security baseline: an extension cannot pop a panel in the user's face without their action. Honor it; don't try to fake gestures with timeouts.

The Two-Step Per-Tab Pattern

For ClipDeck, the eventual side-panel pattern is per-tab — each tab gets its own panel context so we can later filter clips by current site or even maintain per-tab annotations. The setup:

  1. Manifest declares the default path and the sidePanel permission.
  2. Use one global setOptions({ path: 'panel.html', enabled: true }) when every tab shares the same panel. Add per-tab overrides only when URL-specific enablement or paths are a product requirement.
  3. SW separately calls chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }) once at install/startup to make clicking the toolbar icon open the panel instead of the popup — if that's the UX you want.

The Popup Coexistence Choice

ClipDeck ships both popup and panel. You have to pick which the toolbar icon opens by default:

  • Keep action.default_popup: "popup.html" AND openPanelOnActionClick: false → toolbar click opens popup, panel only opens via its own toggle or a button.
  • Remove default_popup AND set openPanelOnActionClick: true → toolbar click opens panel; popup is unreachable from the icon (you can still open it via keyboard or another flow).
  • Keep both: not possible. Chrome forces a choice on each click.

ClipDeck's choice for now: keep the popup as default (it loads faster, has the "reset / quick stats" buttons) and add a "Open clip list" button in the popup that calls chrome.sidePanel.open() in the user-gesture handler.

Manifest declares the path. SW configures per-tab and behavior. open() needs a user gesture. Miss any step, the panel either does not exist, does not appear, or rejects with a misleading error.
Do not manufacture lifecycle listeners. A global panel needs one global option. Use tabs.onActivated or tabs.onUpdated only when the desired option genuinely depends on the active tab or its completed URL.

Code

background.js — one global panel default·javascript
// background.js — one global panel default
chrome.runtime.onInstalled.addListener(async () => {
  await chrome.sidePanel.setOptions({
    path: "panel.html",
    enabled: true,
  });
  // The popup keeps the toolbar click; it opens the panel from its button.
  await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: false });
});
popup.js — open the side panel from a real user click·javascript
// popup.js — Open Panel button (lives inside the popup, which is the user gesture)
async function openSidePanel() {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  if (!tab) return;
  // Calling sidePanel.open inside this handler is fine — the popup click
  // is a user gesture and Chrome carries it through this call.
  await chrome.sidePanel.open({ tabId: tab.id });
  // The popup typically auto-closes after this; that's expected.
}

document.getElementById("openPanelBtn").addEventListener("click", openSidePanel);
background.js — keyboard command opens the panel·javascript
// background.js — also bind a keyboard command for opening the panel
chrome.commands.onCommand.addListener(async (command) => {
  if (command !== "open-panel") return;
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  if (!tab?.id) return;
  await chrome.sidePanel.open({ tabId: tab.id });
});

// In manifest commands block, add alongside save-clip:
//   "open-panel": {
//     "suggested_key": { "default": "Ctrl+Shift+P", "mac": "Command+Shift+P" },
//     "description": "Open the ClipDeck side panel"
//   }

External links

Exercise

Add the global background.js setup and the popup button handler. Reload, open the popup, and click Open Clip List; the native side panel should open. Add the optional keyboard command only if you want that second user-gesture path. Then explain when a per-tab setOptions call would be justified.
Hint
Call sidePanel.open directly from the click or command handler. Do not add onActivated/onUpdated merely to repeat a global option; add them only for URL-dependent behavior.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.