The Chrome Side Panel API represents one of the most significant additions to the extension platform in recent years. Introduced in Chrome 114 and continuously improved through 2026, this API enables developers to create persistent, rich interfaces that remain visible as users navigate across websites. Unlike traditional popup windows that close when users click away, side panels provide a dockable, resizable interface that transforms how extensions can deliver ongoing value to users.
In this comprehensive tutorial, you’ll learn how to build production-ready side panel extensions using TypeScript, with real-world examples inspired by extensions like Tab Suspender Pro. We’ll cover everything from basic setup to advanced patterns including per-tab customization, service worker communication, and responsive design considerations.
The Side Panel API addresses a fundamental limitation of traditional extension popups: their ephemeral nature. When users click away from a popup, it closes, forcing them to reopen it for each interaction. Side panels solve this by remaining open throughout the browsing session, enabling use cases that were previously impractical:
The API provides fine-grained control over which panel displays for each tab, enabling context-aware experiences that adapt to the website being viewed.
Every side panel extension requires specific manifest configuration. Here’s the complete TypeScript-friendly setup:
{
"name": "Tab Suspender Pro",
"version": "2.0.0",
"manifest_version": 3,
"description": "Intelligently manage browser tabs to reduce memory usage",
"permissions": [
"sidePanel",
"storage",
"tabs",
"activeTab"
],
"host_permissions": [
"<all_urls>"
],
"action": {
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"default_title": "Tab Suspender Pro"
},
"side_panel": {
"default_path": "sidepanel/panel.html",
"default_title": "Tab Manager",
"default_icon": {
"16": "icons/panel16.png"
}
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}
The side_panel key accepts three properties:
| Property | Type | Description |
|---|---|---|
default_path |
string | Required path to the HTML file |
default_title |
string | Accessible title for screen readers |
default_icon |
object | 16x16 icon for the panel header |
Chrome provides type definitions for the Side Panel API. Install the types:
npm install --save-dev @types/chrome
The key TypeScript interfaces you’ll work with:
// Side Panel configuration
interface SidePanelOptions {
tabId?: number;
page: string;
title?: string;
}
// Panel behavior configuration
interface PanelBehavior {
openPanelOnActionClick: boolean;
}
// Panel configuration returned from getOptions
interface SidePanelConfig {
page: string;
title?: string;
}
There are two primary ways to open the side panel: user-triggered via the toolbar icon, and programmatically via the API.
User-Triggered Opening:
Configure the panel to open automatically when users click the extension icon:
// background/service-worker.ts
export function initializeSidePanel(): void {
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
}
// Handle extension icon click
chrome.action.onClicked.addListener(async (tab) => {
await chrome.sidePanel.open({ tabId: tab.id });
});
Programmatic Opening:
For more control, open the panel programmatically:
// Open for the current active tab
async function openSidePanelForCurrentTab(): Promise<void> {
try {
await chrome.sidePanel.open();
console.log('Side panel opened successfully');
} catch (error) {
console.error('Failed to open side panel:', error);
}
}
// Open for a specific tab
async function openSidePanelForTab(tabId: number): Promise<void> {
try {
await chrome.sidePanel.open({ tabId });
console.log(`Side panel opened for tab ${tabId}`);
} catch (error) {
console.error(`Failed to open side panel for tab ${tabId}:`, error);
}
}
Note that chrome.sidePanel.open() requires a user gesture in most contexts. Attempting to open the panel without user interaction will fail.
The setOptions method controls which panel displays for each tab:
// Set the default panel for all tabs
async function setGlobalPanel(panelPath: string): Promise<void> {
await chrome.sidePanel.setOptions({
page: panelPath
});
}
// Set panel for a specific tab
async function setTabPanel(tabId: number, panelPath: string): Promise<void> {
await chrome.sidePanel.setOptions({
tabId,
page: panelPath
});
}
// Set panel for current active tab
async function setCurrentTabPanel(panelPath: string): Promise<void> {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (tab.id) {
await chrome.sidePanel.setOptions({
tabId: tab.id,
page: panelPath
});
}
}
Retrieve the current panel configuration:
// Get configuration for a specific tab
async function getTabPanelConfig(tabId: number): Promise<chrome.sidePanel.SidePanelConfig | null> {
return new Promise((resolve) => {
chrome.sidePanel.getOptions(tabId, (config) => {
resolve(config ?? null);
});
});
}
// Get global configuration
async function getGlobalPanelConfig(): Promise<chrome.sidePanel.SidePanelConfig | null> {
return new Promise((resolve) => {
chrome.sidePanel.getOptions(undefined, (config) => {
resolve(config ?? null);
});
});
}
Control whether clicking the extension icon opens the side panel:
// Enable automatic panel opening
async function enableAutoOpen(): Promise<void> {
await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
}
// Disable automatic panel opening
async function disableAutoOpen(): Promise<void> {
await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: false });
}
// Check current behavior setting
async function getPanelBehavior(): Promise<boolean> {
return new Promise((resolve) => {
chrome.sidePanel.getPanelBehavior((behavior) => {
resolve(behavior.openPanelOnActionClick);
});
});
}
One of the Side Panel API’s most powerful features is the ability to display different panels based on the active tab’s context. This enables sophisticated, context-aware experiences.
Here’s how Tab Suspender Pro uses per-tab panels to provide relevant tab management interfaces:
// types/tab-manager.ts
interface TabContext {
url: string;
title: string;
favicon?: string;
isSuspended: boolean;
lastActive?: Date;
}
// Determine which panel to show based on the website
async function configurePanelForTab(tabId: number, url: string): Promise<void> {
let panelPath: string;
if (url.includes('github.com')) {
panelPath = 'panels/github-tools.html';
} else if (url.includes('youtube.com')) {
panelPath = 'panels/youtube-tools.html';
} else if (isProductivitySite(url)) {
panelPath = 'panels/productivity-panel.html';
} else {
// Default to the main tab manager panel
panelPath = 'panels/tab-manager.html';
}
await chrome.sidePanel.setOptions({
tabId,
page: panelPath
});
}
// Listen for tab updates to dynamically change panels
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (changeInfo.status === 'complete' && tab.url) {
configurePanelForTab(tabId, tab.url);
}
});
// Also handle tab switches
chrome.tabs.onActivated.addListener(async (activeInfo) => {
const tab = await chrome.tabs.get(activeInfo.tabId);
if (tab.url) {
configurePanelForTab(activeInfo.tabId, tab.url);
}
});
Some features should always be accessible regardless of the active tab:
// Global search panel that's always available
async function setGlobalSearchPanel(): Promise<void> {
await chrome.sidePanel.setOptions({
page: 'panels/global-search.html'
});
}
// Quick actions panel available everywhere
async function setGlobalQuickActionsPanel(): Promise<void> {
await chrome.sidePanel.setOptions({
page: 'panels/quick-actions.html'
});
}
The side panel operates in its own execution context, separate from the service worker. Communication between these contexts uses Chrome’s message passing system.
// sidepanel/panel.ts - Sending messages to background
interface PanelMessage {
type: 'GET_TAB_INFO' | 'SUSPEND_TAB' | 'UPDATE_SETTINGS';
payload?: unknown;
}
async function sendMessageToBackground(message: PanelMessage): Promise<unknown> {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage(message, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else {
resolve(response);
}
});
});
}
// Example: Get tab information when panel opens
async function requestTabInfo(): Promise<TabContext | null> {
try {
const response = await sendMessageToBackground({
type: 'GET_TAB_INFO'
});
return response as TabContext;
} catch (error) {
console.error('Failed to get tab info:', error);
return null;
}
}
// Example: Suspend a tab from the panel
async function suspendTab(tabId: number): Promise<boolean> {
try {
const response = await sendMessageToBackground({
type: 'SUSPEND_TAB',
payload: { tabId }
});
return (response as { success: boolean }).success;
} catch (error) {
console.error('Failed to suspend tab:', error);
return false;
}
}
// background/service-worker.ts
interface BackgroundMessage {
type: string;
payload?: unknown;
}
chrome.runtime.onMessage.addListener(
(message: BackgroundMessage, sender, sendResponse) => {
switch (message.type) {
case 'GET_TAB_INFO':
handleGetTabInfo(sender.tab?.id, sendResponse);
return true; // Keep message channel open for async response
case 'SUSPEND_TAB':
handleSuspendTab(message.payload as { tabId: number }, sendResponse);
return true;
case 'UPDATE_SETTINGS':
handleUpdateSettings(message.payload, sendResponse);
return true;
default:
console.warn('Unknown message type:', message.type);
}
}
);
async function handleGetTabInfo(
tabId: number | undefined,
sendResponse: (response: TabContext) => void
): Promise<void> {
if (!tabId) {
sendResponse({ url: '', title: '', isSuspended: false });
return;
}
const tab = await chrome.tabs.get(tabId);
sendResponse({
url: tab.url || '',
title: tab.title || '',
favicon: tab.favIconUrl,
isSuspended: isTabSuspended(tab),
lastActive: new Date(tab.lastAccessed || Date.now())
});
}
async function handleSuspendTab(
payload: { tabId: number },
sendResponse: (response: { success: boolean }) => void
): Promise<void> {
try {
await chrome.tabs.discard(payload.tabId);
sendResponse({ success: true });
} catch (error) {
console.error('Suspend failed:', error);
sendResponse({ success: false });
}
}
For continuous communication, establish a long-lived port connection:
// sidepanel/panel.ts - Establish persistent connection
let messagePort: chrome.runtime.Port | null = null;
function connectToBackground(): void {
messagePort = chrome.runtime.connect({ name: 'sidepanel-connection' });
messagePort.onMessage.addListener((message) => {
console.log('Received from background:', message);
handleBackgroundMessage(message);
});
messagePort.onDisconnect.addListener(() => {
console.log('Disconnected from background');
messagePort = null;
// Attempt reconnection after a delay
setTimeout(connectToBackground, 1000);
});
}
function handleBackgroundMessage(message: unknown): void {
// Handle updates from background service worker
console.log('Background update:', message);
}
// Send messages through the port
function sendViaPort(message: unknown): void {
if (messagePort) {
messagePort.postMessage(message);
}
}
// background/service-worker.ts - Handle connections
chrome.runtime.onConnect.addListener((port) => {
if (port.name === 'sidepanel-connection') {
port.onMessage.addListener((message) => {
// Handle messages from side panel
console.log('Message from panel:', message);
});
// Send periodic updates to connected panel
const interval = setInterval(() => {
if (port.sender?.tab?.id) {
port.postMessage({
type: 'TAB_UPDATE',
payload: { tabId: port.sender.tab.id }
});
}
}, 5000);
port.onDisconnect.addListener(() => {
clearInterval(interval);
});
}
});
For persistent state that survives service worker restarts:
// sidepanel/panel.ts - Persist panel state
interface PanelState {
lastOpenedTab?: number;
panelWidth?: number;
filterPreferences?: {
showSuspended: boolean;
sortBy: 'activity' | 'title' | 'domain';
};
}
async function savePanelState(state: Partial<PanelState>): Promise<void> {
await chrome.storage.local.set(state);
}
async function loadPanelState(): Promise<PanelState> {
const result = await chrome.storage.local.get([
'lastOpenedTab',
'panelWidth',
'filterPreferences'
]);
return result as PanelState;
}
// background/service-worker.ts - Share extension-wide state
interface ExtensionState {
suspendedTabs: Map<number, Date>;
globalSettings: {
autoSuspend: boolean;
suspendDelay: number;
};
}
async function saveExtensionState(state: Partial<ExtensionState>): Promise<void> {
await chrome.storage.local.set(state);
}
Understanding the side panel’s lifecycle is crucial for building reliable extensions.
// sidepanel/panel.ts - Handle lifecycle
document.addEventListener('DOMContentLoaded', async () => {
console.log('Side panel loaded');
// Initialize connection to background
connectToBackground();
// Load saved state
const state = await loadPanelState();
applyStateToUI(state);
// Set up visibility change handler
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
console.log('Panel hidden - save state');
savePanelState(getCurrentUIState());
} else {
console.log('Panel visible - refresh data');
refreshPanelData();
}
});
});
function refreshPanelData(): void {
// Fetch latest data when panel becomes visible
requestTabInfo().then(updateTabDisplay);
}
The service worker can terminate while the panel remains open. Handle reconnection gracefully:
// sidepanel/panel.ts - Reconnection logic
class PanelConnectionManager {
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private reconnectDelay = 1000;
async ensureConnection(): Promise<void> {
if (!chrome.runtime?.id) {
console.error('Extension context invalidated');
return;
}
try {
const port = chrome.runtime.connect({ name: 'sidepanel' });
this.setupPortListeners(port);
} catch (error) {
this.handleReconnectionFailure(error);
}
}
private setupPortListeners(port: chrome.runtime.Port): void {
port.onMessage.addListener(this.handleMessage.bind(this));
port.onDisconnect.addListener(() => {
this.scheduleReconnect();
});
}
private scheduleReconnect(): void {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
setTimeout(
() => this.ensureConnection(),
this.reconnectDelay * this.reconnectAttempts
);
}
}
private handleReconnectionFailure(error: unknown): void {
console.error('Connection failed:', error);
this.scheduleReconnect();
}
private handleMessage(message: unknown): void {
console.log('Received:', message);
}
}
The side panel can resize from 300px to 600px width, controlled by users. Your panel must adapt:
// sidepanel/panel.ts - Responsive design handling
type Breakpoint = 'compact' | 'medium' | 'expanded';
function getBreakpoint(width: number): Breakpoint {
if (width < 350) return 'compact';
if (width < 480) return 'medium';
return 'expanded';
}
function applyResponsiveStyles(breakpoint: Breakpoint): void {
document.body.classList.remove('compact', 'medium', 'expanded');
document.body.classList.add(breakpoint);
}
// Use ResizeObserver for efficient monitoring
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const width = entry.contentRect.width;
const breakpoint = getBreakpoint(width);
applyResponsiveStyles(breakpoint);
}
});
resizeObserver.observe(document.body);
/* sidepanel/styles.css */
:root {
--panel-compact-width: 300px;
--panel-medium-width: 400px;
--panel-expanded-width: 600px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 0;
transition: all 0.2s ease;
}
/* Compact layout - essential info only */
body.compact .secondary-content { display: none; }
body.compact .tab-preview { display: none; }
body.compact .action-buttons { flex-direction: column; }
/* Medium layout - some additional details */
body.medium .detailed-stats { display: none; }
body.medium .full-description { display: none; }
/* Expanded layout - everything visible */
body.expanded .tab-preview { max-height: 200px; }
Here’s a complete example combining all concepts:
// background/tab-manager.ts
interface TabInfo {
id: number;
url: string;
title: string;
favicon: string;
lastActive: number;
pinned: boolean;
audible: boolean;
suspended: boolean;
}
class TabManager {
private tabs: Map<number, TabInfo> = new Map();
async initialize(): Promise<void> {
// Configure side panel behavior
await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
// Listen for tab changes
chrome.tabs.onCreated.addListener((tab) => this.handleTabCreated(tab));
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) =>
this.handleTabUpdated(tabId, changeInfo, tab)
);
chrome.tabs.onRemoved.addListener((tabId) => this.handleTabRemoved(tabId));
// Load existing tabs
const existingTabs = await chrome.tabs.query({});
for (const tab of existingTabs) {
if (tab.id) {
this.tabs.set(tab.id, this.createTabInfo(tab));
}
}
}
private createTabInfo(tab: chrome.tabs.Tab): TabInfo {
return {
id: tab.id!,
url: tab.url || '',
title: tab.title || 'Untitled',
favicon: tab.favIconUrl || '',
lastActive: tab.lastAccessed || Date.now(),
pinned: tab.pinned,
audible: tab.audible || false,
suspended: false
};
}
private handleTabCreated(tab: chrome.tabs.Tab): void {
if (tab.id) {
this.tabs.set(tab.id, this.createTabInfo(tab));
this.broadcastUpdate();
}
}
private handleTabUpdated(
tabId: number,
changeInfo: chrome.tabs.TabChangeInfo,
tab: chrome.tabs.Tab
): void {
const existing = this.tabs.get(tabId);
if (existing) {
this.tabs.set(tabId, { ...existing, ...this.createTabInfo(tab) });
this.broadcastUpdate();
}
}
private handleTabRemoved(tabId: number): void {
this.tabs.delete(tabId);
this.broadcastUpdate();
}
private broadcastUpdate(): void {
// Notify all connected panels
chrome.runtime.sendMessage({
type: 'TABS_UPDATED',
payload: Array.from(this.tabs.values())
});
}
async suspendTab(tabId: number): Promise<boolean> {
try {
await chrome.tabs.discard(tabId);
const tab = this.tabs.get(tabId);
if (tab) {
tab.suspended = true;
}
return true;
} catch (error) {
console.error('Failed to suspend tab:', error);
return false;
}
}
}
// Initialize on service worker startup
const tabManager = new TabManager();
tabManager.initialize();
// sidepanel/tab-manager-panel.ts
class TabManagerPanel {
private tabs: TabInfo[] = [];
async initialize(): Promise<void> {
// Set up message handlers
chrome.runtime.onMessage.addListener((message) => {
if (message.type === 'TABS_UPDATED') {
this.tabs = message.payload;
this.render();
}
});
// Request initial data
const response = await chrome.runtime.sendMessage({ type: 'GET_TABS' });
if (response) {
this.tabs = response;
this.render();
}
// Set up event listeners
this.setupEventListeners();
}
private setupEventListeners(): void {
document.getElementById('suspend-btn')?.addEventListener('click', async () => {
const activeTab = await this.getActiveTabId();
if (activeTab) {
await chrome.runtime.sendMessage({
type: 'SUSPEND_TAB',
payload: { tabId: activeTab }
});
}
});
}
private render(): void {
const container = document.getElementById('tabs-container');
if (!container) return;
container.innerHTML = this.tabs.map(tab => `
<div class="tab-item ${tab.suspended ? 'suspended' : ''}">
<img src="${tab.favicon}" class="tab-favicon" />
<span class="tab-title">${this.escapeHtml(tab.title)}</span>
${tab.pinned ? '<span class="pin-icon">📌</span>' : ''}
</div>
`).join('');
}
private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
private async getActiveTabId(): Promise<number | null> {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
return tab.id ?? null;
}
}
// Initialize panel
document.addEventListener('DOMContentLoaded', () => {
const panel = new TabManagerPanel();
panel.initialize();
});
| Feature | Side Panel | Popup |
|---|---|---|
| Persistence | Stays open during navigation | Closes on blur |
| Width | Resizable 300-600px | Fixed ~300px |
| Height | Full viewport height | Limited ~400px |
| Lifetime | Independent of user action | Triggered by click |
| Multiple panels | Per-tab customization | Single panel |
| Resource usage | Higher (always rendered) | Lower (on-demand) |
Side panels consume resources while open. Optimize for performance:
// Lazy load panel content
async function lazyLoadContent(): Promise<void> {
// Only load heavy content when panel is visible
if (!document.hidden) {
const content = await import('./heavy-component.js');
content.initialize();
}
}
// Use requestAnimationFrame for smooth updates
function animateTabList(tabs: TabInfo[]): void {
requestAnimationFrame(() => {
renderTabs(tabs);
});
}
// Debounce resize handlers
function debounce<T extends (...args: unknown[]) => void>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
}
const handleResize = debounce(() => {
// Handle resize
}, 150);
window.addEventListener('resize', handleResize);
Follow security best practices:
// Always validate messages from the panel
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
// Validate sender is from your extension
if (sender.id !== chrome.runtime.id) {
sendResponse({ error: 'Unauthorized' });
return false;
}
// Validate message structure
if (!message.type || typeof message.type !== 'string') {
sendResponse({ error: 'Invalid message' });
return false;
}
// Process valid messages
return true;
});
// Use Content Security Policy
// In manifest.json:
{
"content_security_policy": {
"extension_page": "script-src 'self'; style-src 'self' 'unsafe-inline'"
}
}
The Chrome Side Panel API opens exciting possibilities for extension developers. Unlike traditional popups, side panels provide persistent, context-aware interfaces that transform the user experience. Throughout this tutorial, you’ve learned how to:
Extensions like Tab Suspender Pro demonstrate the power of side panels for ongoing tab management. By following the patterns and best practices in this guide, you can build sophisticated, production-ready extensions that provide lasting value to users.
For more information, consult the official Chrome Side Panel documentation and explore additional resources at zovo.one.
This guide is part of the Chrome Extension Development series. For more tutorials on building production-ready extensions, visit our guides section.