Chrome Extension Side Panel — Best Practices
15 min readSide Panel Patterns
Overview
The Side Panel API reference covers the basics. This guide provides production patterns for building rich side panel experiences: tab-specific panels, navigation, real-time page interaction, persistent state, and responsive layouts.
Pattern 1: Tab-Specific Side Panels
Show different content based on which tab is active:
// background.ts
chrome.tabs.onActivated.addListener(async ({ tabId }) => {
const tab = await chrome.tabs.get(tabId);
const url = tab.url ?? "";
if (url.includes("github.com")) {
await chrome.sidePanel.setOptions({
tabId,
path: "sidepanel-github.html",
enabled: true,
});
} else if (url.includes("docs.google.com")) {
await chrome.sidePanel.setOptions({
tabId,
path: "sidepanel-docs.html",
enabled: true,
});
} else {
// Use default panel for other sites
await chrome.sidePanel.setOptions({
tabId,
path: "sidepanel.html",
enabled: true,
});
}
});
// Also update when a tab navigates
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo) => {
if (changeInfo.url) {
// Re-evaluate which panel to show
await updatePanelForTab(tabId, changeInfo.url);
}
});
Pattern 2: Open Side Panel from Action Click
Replace the popup with a side panel toggle:
// background.ts
// In manifest.json, remove "default_popup" from action and add:
// "side_panel": { "default_path": "sidepanel.html" }
chrome.action.onClicked.addListener(async (tab) => {
if (!tab.id) return;
await chrome.sidePanel.open({ tabId: tab.id });
});
// Or open for the whole window
chrome.action.onClicked.addListener(async (tab) => {
if (!tab.windowId) return;
await chrome.sidePanel.open({ windowId: tab.windowId });
});
Pattern 3: Side Panel with In-Page Navigation
Build a SPA-like experience within the side panel:
// sidepanel.ts
type Route = "home" | "settings" | "search" | "detail";
class SidePanelRouter {
private container: HTMLElement;
private currentRoute: Route = "home";
private history: Route[] = ["home"];
constructor(containerId: string) {
this.container = document.getElementById(containerId)!;
this.render();
// Handle back navigation
window.addEventListener("popstate", () => {
this.history.pop();
this.currentRoute = this.history[this.history.length - 1] ?? "home";
this.render();
});
}
navigate(route: Route) {
this.currentRoute = route;
this.history.push(route);
window.history.pushState({ route }, "", `#${route}`);
this.render();
}
back() {
if (this.history.length > 1) {
window.history.back();
}
}
private render() {
const views: Record<Route, () => string> = {
home: () => `
<h2>Home</h2>
<button data-navigate="search">Search</button>
<button data-navigate="settings">Settings</button>
`,
settings: () => `
<button data-back>Back</button>
<h2>Settings</h2>
<label>
<input type="checkbox" id="dark-mode" /> Dark mode
</label>
`,
search: () => `
<button data-back>Back</button>
<h2>Search</h2>
<input type="search" id="search-input" placeholder="Search..." />
<div id="search-results"></div>
`,
detail: () => `
<button data-back>Back</button>
<h2>Detail</h2>
<div id="detail-content"></div>
`,
};
this.container.innerHTML = views[this.currentRoute]();
this.bindNavigation();
}
private bindNavigation() {
this.container.querySelectorAll<HTMLElement>("[data-navigate]").forEach((el) => {
el.addEventListener("click", () => {
this.navigate(el.dataset.navigate as Route);
});
});
this.container.querySelectorAll("[data-back]").forEach((el) => {
el.addEventListener("click", () => this.back());
});
}
}
const router = new SidePanelRouter("app");
Pattern 4: Real-Time Page Interaction
The side panel can communicate with the active tab’s content script:
// sidepanel.ts — Send commands to the active page
import { createMessenger } from "@theluckystrike/webext-messaging";
type Messages = {
"get-page-info": {
request: void;
response: { title: string; wordCount: number; url: string };
};
"highlight-text": {
request: { query: string; color: string };
response: { count: number };
};
"scroll-to-element": {
request: { selector: string };
response: { found: boolean };
};
};
const msg = createMessenger<Messages>();
async function getActiveTabId(): Promise<number | undefined> {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
return tab?.id;
}
async function sendToActiveTab<K extends keyof Messages>(
type: K,
payload: Messages[K]["request"]
): Promise<Messages[K]["response"]> {
const tabId = await getActiveTabId();
if (!tabId) throw new Error("No active tab");
return msg.sendTab({ tabId }, type, payload);
}
// UI event handlers
document.getElementById("highlight-btn")?.addEventListener("click", async () => {
const query = (document.getElementById("search-input") as HTMLInputElement).value;
const result = await sendToActiveTab("highlight-text", {
query,
color: "#ffeb3b",
});
document.getElementById("match-count")!.textContent = `${result.count} matches`;
});
// content.ts — Respond to side panel commands
import { createMessenger } from "@theluckystrike/webext-messaging";
const msg = createMessenger<Messages>();
msg.onMessage("get-page-info", async () => ({
title: document.title,
wordCount: document.body.innerText.split(/\s+/).length,
url: location.href,
}));
msg.onMessage("highlight-text", async ({ query, color }) => {
// Remove previous highlights
document.querySelectorAll(".ext-highlight").forEach((el) => {
const parent = el.parentNode!;
parent.replaceChild(document.createTextNode(el.textContent ?? ""), el);
parent.normalize();
});
if (!query) return { count: 0 };
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
const matches: { node: Text; index: number }[] = [];
while (walker.nextNode()) {
const node = walker.currentNode as Text;
const idx = node.textContent?.toLowerCase().indexOf(query.toLowerCase()) ?? -1;
if (idx >= 0) matches.push({ node, index: idx });
}
for (const { node, index } of matches.reverse()) {
const range = document.createRange();
range.setStart(node, index);
range.setEnd(node, index + query.length);
const mark = document.createElement("mark");
mark.className = "ext-highlight";
mark.style.backgroundColor = color;
range.surroundContents(mark);
}
return { count: matches.length };
});
Pattern 5: Persistent Side Panel State
The side panel stays open across tab switches, but its JavaScript context reloads. Persist state:
// sidepanel.ts
import { createStorage, defineSchema } from "@theluckystrike/webext-storage";
const schema = defineSchema({
panelState: {
currentRoute: "home" as string,
searchQuery: "",
scrollPosition: 0,
collapsedSections: [] as string[],
},
});
const storage = createStorage({ schema, area: "session" });
// Save state on every change
async function saveState(updates: Partial<typeof schema.panelState>) {
const current = await storage.get("panelState");
await storage.set("panelState", { ...current, ...updates });
}
// Restore state on load
async function restoreState() {
const state = await storage.get("panelState");
if (!state) return;
// Restore route
if (state.currentRoute !== "home") {
router.navigate(state.currentRoute as Route);
}
// Restore search query
const searchInput = document.getElementById("search-input") as HTMLInputElement;
if (searchInput && state.searchQuery) {
searchInput.value = state.searchQuery;
}
// Restore scroll position
requestAnimationFrame(() => {
document.documentElement.scrollTop = state.scrollPosition;
});
}
document.addEventListener("DOMContentLoaded", restoreState);
// Save scroll position periodically
let scrollTimer: ReturnType<typeof setTimeout>;
document.addEventListener("scroll", () => {
clearTimeout(scrollTimer);
scrollTimer = setTimeout(() => {
saveState({ scrollPosition: document.documentElement.scrollTop });
}, 200);
});
Pattern 6: Responsive Side Panel Layout
Side panels can be resized by the user. Handle varying widths:
/* sidepanel.css */
:root {
--panel-padding: 12px;
}
body {
margin: 0;
padding: var(--panel-padding);
font-family: system-ui, sans-serif;
font-size: 14px;
line-height: 1.5;
color: #333;
/* Prevent horizontal overflow */
overflow-x: hidden;
}
/* Stack layout for narrow panels */
.card-grid {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
/* Side-by-side when panel is wide enough */
@container (min-width: 400px) {
.card-grid {
grid-template-columns: 1fr 1fr;
}
}
/* Use container queries for panel width awareness */
#app {
container-type: inline-size;
}
/* Truncate long text */
.truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Compact controls */
.toolbar {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.toolbar button {
flex: 0 0 auto;
padding: 6px 12px;
font-size: 13px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
}
.toolbar button:hover {
background: #f5f5f5;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
body {
background: #1a1a1a;
color: #e0e0e0;
}
.toolbar button {
background: #2a2a2a;
border-color: #444;
color: #e0e0e0;
}
}
Pattern 7: Side Panel with Background Sync
Keep the side panel updated with live data from the service worker:
// background.ts — Push updates to side panel
function broadcastToSidePanel(data: unknown) {
chrome.runtime.sendMessage({ type: "side-panel-update", data }).catch(() => {
// Side panel may not be open — ignore
});
}
// Poll or listen for data changes
chrome.alarms.create("check-updates", { periodInMinutes: 1 });
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name === "check-updates") {
const freshData = await fetchUpdates();
broadcastToSidePanel(freshData);
}
});
// sidepanel.ts — Receive live updates
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type === "side-panel-update") {
updateUI(msg.data);
}
});
Pattern 8: Disabling Side Panel Per-Site
// background.ts — Disable side panel on specific sites
const BLOCKED_SITES = ["chrome://", "chrome-extension://", "about:"];
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo) => {
if (!changeInfo.url) return;
const blocked = BLOCKED_SITES.some((prefix) => changeInfo.url!.startsWith(prefix));
await chrome.sidePanel.setOptions({
tabId,
enabled: !blocked,
});
});
Summary
| Pattern | Use Case |
|---|---|
| Tab-specific panels | Different UI per site/context |
| Action click open | Replace popup with persistent side panel |
| In-panel navigation | Multi-view SPA within the panel |
| Page interaction | Highlight, scroll, extract from active tab |
| Persistent state | Survive panel reloads and tab switches |
| Responsive layout | Adapt to user-resized panel width |
| Background sync | Live data pushed from service worker |
| Per-site disable | Hide panel on incompatible pages |
The Side Panel API is Chrome’s answer to the ephemeral popup. Use it when your extension needs persistent, always-available UI that interacts deeply with page content. -e —
Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.