Opening the Side Panel from a User Gesture
Open chrome.sidePanel programmatically without 'may only be called in response to a user gesture' errors — openPanelOnActionClick, context menus, commands, and keeping the gesture alive.
Table of Contents
chrome.sidePanel.open() has one rule that trips up almost everyone: it must be called in direct response to a user gesture, synchronously enough that the browser still considers the gesture active. An await before the call, a round trip to storage, a message from a content script — any of these can consume the gesture, and the call rejects with “sidePanel.open() may only be called in response to a user gesture”. This guide is part of side panel and DevTools interfaces.
Which events carry a gesture
Step-by-step
1. Let the toolbar button open it without code
The simplest correct implementation involves no open() call at all.
1{
2 "permissions": ["sidePanel"],
3 "side_panel": { "default_path": "panel.html" },
4 "action": { "default_title": "Open Reader panel" }
5}
1chrome.runtime.onInstalled.addListener(() => {
2 chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
3});
Execution context: the service worker. With this set, the browser opens the panel itself when the action is clicked — no gesture to preserve because no code runs in between. The popup and action.onClicked are both bypassed, so choose this only when the panel is the extension’s primary surface.
2. Call open() first, then do the work
When opening from an event handler, open() must be the first asynchronous thing you do.
1// Broken: the await consumes the gesture.
2chrome.contextMenus.onClicked.addListener(async (info, tab) => {
3 const { panelEnabled } = await chrome.storage.local.get("panelEnabled");
4 if (panelEnabled) await chrome.sidePanel.open({ tabId: tab.id }); // rejects
5});
6
7// Correct: open immediately, decide afterwards.
8chrome.contextMenus.onClicked.addListener((info, tab) => {
9 if (info.menuItemId !== "open-panel") return;
10 chrome.sidePanel.open({ tabId: tab.id }); // no await before this
11 chrome.storage.session.set({ panelContext: { selection: info.selectionText ?? null } });
12});
Execution context: the service worker, with the listener registered at the top level. Calling open() synchronously in the handler — not after an await — is what keeps the gesture. Any data the panel needs goes into storage after the call and is read by the panel when it loads.
3. Open from a keyboard command
1{
2 "commands": {
3 "_execute_side_panel": {
4 "suggested_key": { "default": "Alt+Shift+P" },
5 "description": "__MSG_cmdOpenPanel__"
6 }
7 }
8}
Execution context: parsed at install (Chrome 116+). Like _execute_action, this reserved command opens the panel without any handler, so there is no gesture to lose. Its place in the four-shortcut budget is discussed in the _execute_action command and the four-shortcut limit.
4. Open from a button in the popup
1// popup.js
2document.querySelector("#open-panel").addEventListener("click", async () => {
3 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
4 await chrome.sidePanel.open({ windowId: tab.windowId });
5 window.close();
6});
Execution context: the popup document. The click in the popup is a gesture, and tabs.query is fast enough in practice to stay within it — but if you see intermittent rejections, pass windowId from chrome.windows.getCurrent instead, or open by window without querying the tab. Closing the popup afterwards avoids two extension surfaces competing for attention.
5. Do not route page clicks through a message
A button your content script injects into a page is a click on the page, not on the extension. Messaging the worker to open the panel from there fails.
1// content script — this will not work
2button.addEventListener("click", () => chrome.runtime.sendMessage({ type: "open-panel" }));
Execution context: the content script. The worker receives the message without a gesture and open() rejects. The supported alternatives are to have the page button open the popup’s equivalent in a new tab, or to show a hint pointing at the toolbar button or keyboard shortcut.
Passing context into the panel
Because open() has to come first, the panel usually opens before the worker has written whatever the panel should show — the selected text, the link that was right-clicked, the reason it was opened. The panel therefore needs to handle “context arrives slightly after I do”.
1// panel.js
2async function readContext() {
3 const { panelContext } = await chrome.storage.session.get("panelContext");
4 if (panelContext) return panelContext;
5 return new Promise((resolve) => {
6 const onChange = (changes, area) => {
7 if (area === "session" && changes.panelContext) {
8 chrome.storage.onChanged.removeListener(onChange);
9 resolve(changes.panelContext.newValue);
10 }
11 };
12 chrome.storage.onChanged.addListener(onChange);
13 });
14}
15
16render(await readContext());
Execution context: the side panel document. Reading once and then waiting for the change covers both orderings — context written before the panel loaded, and after. The panel is long-lived, so it should also keep listening for later context updates when the user right-clicks something else while it is open, which is where the side panel’s persistence, described in building a side panel UI in MV3, pays off.
Why the gesture rule exists
It is tempting to treat the gesture requirement as an obstacle. It is better understood as a promise to users: the side panel takes a permanent slice of the browser window, and Chrome guarantees that no extension can claim that space without the user asking. An extension that could open its panel from an alarm or a page message could pop it open on every site visit — exactly the behaviour the requirement prevents.
That framing also explains which workarounds are legitimate. Anything that makes the user’s action open the panel more directly is fine: a toolbar behaviour, a keyboard shortcut, a context-menu item, a clear button in the popup. Anything that tries to manufacture a gesture — replaying a page click, holding a gesture across a delay — is working against the platform, will break as Chrome tightens the rules, and is the kind of behaviour reviewers look for.
Cross-browser variation
- Chrome / Edge:
sidePanel.open()from Chrome 116, gesture-gated.setPanelBehavior({ openPanelOnActionClick })and_execute_side_panelopen it without code. - Firefox: no
sidePanel.browser.sidebarAction.open()is the equivalent and is also gesture-gated;_execute_sidebar_actionopens it from a shortcut. The adapter pattern is in side panel support across browsers. - Safari: no side panel or sidebar API. Fall back to the popup or an extension page in a tab.
- All three: a gesture is never transferable across a message, an alarm or a timer. If the action did not start with the user touching browser or extension UI, the panel cannot be opened programmatically.
Verification
- Trigger each entry point — toolbar, context menu, shortcut, popup button — and confirm the panel opens every time with DevTools closed.
- Reproduce the failure deliberately by adding an
awaitbeforeopen()and confirm the rejection message:
1// in the worker console, outside any gesture
2await chrome.sidePanel.open({ windowId: (await chrome.windows.getCurrent()).id });
3// Error: `sidePanel.open()` may only be called in response to a user gesture.
Execution context: the service worker console. Running it here, without a gesture, shows the exact error you will see in the field — worth recognising.
- Right-click a selection, open the panel, and confirm it shows that selection; right-click another while the panel is open and confirm it updates.
- On Firefox, confirm the sidebar opens through the equivalent path.
FAQ
Can I open the side panel when my extension installs?
No — installation is not a gesture. Open an onboarding tab instead and put a button there; a click on an extension page is a gesture.
Why does open() work in development and fail for users?
Usually because an await is fast on your machine and slow on theirs, so the gesture expires only under real conditions. Move open() before every await.
Should openPanelOnActionClick be the default?
Only if the panel is the primary UI. It replaces the popup entirely for the toolbar button, so users lose any quick actions the popup offered.
Can I close the side panel programmatically?
Not with a dedicated API in most Chrome versions — the user closes it. Setting enabled: false with sidePanel.setOptions for the current tab removes it, which is the practical workaround when a flow is finished. Closing from inside the panel with window.close() also works.
Related
- Building a side panel UI in MV3 — the panel this opens.
- Showing different side panel content per tab — opening with per-tab paths.
- Handling selection and link context menu clicks — the gesture most panels open from.
- Side panel and DevTools interfaces — the parent guide.