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/54 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 — the HTML file inside your extension that Chrome will load when the panel opens. Required for the panel to exist at all.
  • "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. SW listens to chrome.tabs.onActivated. On each event, call chrome.sidePanel.setOptions({ tabId, path: 'panel.html', enabled: true }).
  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.
The silent setOptions race. If you call setOptions({ tabId, path, enabled: true }) too early — before Chrome has finished registering the tab — it can silently fail. The safest pattern is to listen to chrome.tabs.onActivated and chrome.tabs.onUpdated together: re-apply setOptions whenever a tab becomes active OR finishes loading. Slightly redundant, immune to ordering.

Code

background.js — per-tab side panel registration on activate + load·javascript
// background.js — register per-tab side panel, enable open-on-action
chrome.runtime.onInstalled.addListener(async () => {
  // Toolbar-icon click should NOT open the panel — popup keeps that slot.
  await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: false });
});

chrome.tabs.onActivated.addListener(async ({ tabId }) => {
  await chrome.sidePanel.setOptions({
    tabId,
    path: "panel.html",
    enabled: true,
  });
});

chrome.tabs.onUpdated.addListener(async (tabId, info) => {
  if (info.status !== "complete") return;
  await chrome.sidePanel.setOptions({
    tabId,
    path: "panel.html",
    enabled: true,
  });
});
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 first two code blocks to clipdeck/background.js and clipdeck/popup.js respectively. Add a <button id="openPanelBtn">Open Clip List</button> to clipdeck/popup.html. Reload the extension. Click the toolbar icon — popup appears. Click Open Clip List — the side panel opens with your clips. Switch to another tab, click the side-panel toggle to confirm the panel reopens with the same content. Optionally, add the third code block (keyboard command) and the matching manifest entry, then test Ctrl+Shift+P. Confirm: panel opens; calling chrome.sidePanel.open from a setTimeout in the SW console (just type it into the SW DevTools) rejects with the user-gesture error — that's the security boundary you should respect.
Hint
If the popup's Open Clip List button does not open the panel, check the popup's DevTools (right-click in popup → Inspect → Console) for Side panel can only be opened by a user gesture — usually means you awaited something else before calling open() and Chrome lost the gesture context. The fix is to call sidePanel.open() FIRST in the handler, then do other work after. If onActivated does not fire as you expect, remember the active tab on extension load is not 're-activated' — you may need a startup-time pass that calls setOptions for whatever the currently active tab is. The tabs.onUpdated path covers this in practice.

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.