Back to articles

Chrome extension side panels: build a UI that stays open while you browse

A hands-on Manifest V3 tutorial: build a Chrome extension side panel that persists across tabs, then make it per-site. Full runnable code, ~25 minutes.

Maxim Kosterin
13 min read
A hairline browser-window outline whose right edge holds a tall orange watercolor pane with three short note strokes — a side panel docked beside the page.
A hairline browser-window outline whose right edge holds a tall orange watercolor pane with three short note strokes — a side panel docked beside the page.

Popups are the wrong tool for half the things we build. You click the toolbar icon, a little window pops over the page, you glance at it, and the moment you click anywhere else it vanishes — taking whatever you were doing with it. That's fine for a quick toggle. It's miserable for anything the user wants to keep next to them while they browse: notes, a running checklist, a reading queue, a live log.

The fix is the side panel — a persistent, resizable pane that docks to the side of the browser and stays put as you switch tabs. In this tutorial you'll build one from an empty folder: first a global notepad that survives tab switches, then a smarter version that keeps separate notes per website. Everything here is copy-paste runnable.

What you'll build: a "browsing notepad" side panel, Manifest V3, Chrome/Edge. Difficulty: beginner-to-intermediate — you need Chrome and a text editor, nothing else. Time: ~25 minutes.

Why a side panel and not a popup

It helps to know exactly what you're choosing between, because the browser draws a hard line here. As Matt Frisbie lays out in Building Browser Extensions (Apress, 2025), a popup is a transient interface — it opens on a user action and closes the instant it loses focus, so the user can't interact with the page and the popup at the same time. The side panel is the opposite: a persistent container that stays open until the user explicitly closes it, and updates as the active tab changes.

There's a second distinction worth internalizing. The book frames every extension interface as either a plain managed page (an HTML file your extension serves, which happens to get access to the chrome.* APIs) or an extension UI (the browser-controlled container — popup, options, side panel — with rules about how and when it shows up). Your sidepanel.html is just a managed page. The side panel is the container the browser wraps around it. Keep those separate in your head and the API stops feeling magic.

Side panel architecture: the toolbar action tells the service worker to open the panel; the panel page reads and writes chrome.storage.local and reacts to tab changes.

The API itself has been stable since Chrome 114, so there's no origin-trial or flag dance. Let's build.

The file tree

Here's everything we're going to create. It's small:

browsing-notepad/
├── manifest.json        # permissions + the side_panel key
├── service-worker.js    # opens the panel when the icon is clicked
├── sidepanel.html       # the panel's markup
├── sidepanel.css        # a little styling (panels are tall and narrow)
└── sidepanel.js         # load + save notes

Make a folder called browsing-notepad and let's fill it in, one file per step.

Step 1: the manifest

Two things make a side panel exist: the sidePanel permission, and the side_panel manifest key pointing at your panel's HTML. The action key (even empty) gives you a toolbar icon to click.

manifest.json

{
  "manifest_version": 3,
  "name": "Browsing Notepad",
  "version": "1.0",
  "description": "A notepad that stays open in the side panel while you browse.",
  "permissions": ["sidePanel"],
  "background": {
    "service_worker": "service-worker.js"
  },
  "action": {
    "default_title": "Open notepad"
  },
  "side_panel": {
    "default_path": "sidepanel.html"
  }
}

Notice how little we're asking for. The only permission is sidePanel, which shows the user no scary warning string at install time. That restraint matters — every permission you add is a line of friction and a line of risk, and this build genuinely needs nothing more yet. If you're hand-writing manifests a lot, our free in-browser Manifest V3 generator will scaffold this block (permissions, keys, icons) without a sign-up.

Step 2: the panel document

The panel is a normal HTML page. Because panels are tall and narrow by default, design for that shape from the start — one column, room to grow.

sidepanel.html

<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <link rel="stylesheet" href="sidepanel.css" />
  </head>
  <body>
    <header>
      <h1>Notepad</h1>
      <span id="scope">All sites</span>
    </header>
    <textarea id="notes" placeholder="Jot something down…"></textarea>
    <footer><span id="status">Saved</span></footer>
    <script src="sidepanel.js"></script>
  </body>
</html>

sidepanel.css

* { box-sizing: border-box; }
body {
  margin: 0;
  height: 100vh;
  display: flex;
  flex-direction: column;
  font-family: system-ui, sans-serif;
}
header {
  padding: 12px 14px;
  border-bottom: 1px solid #e5e2dc;
}
header h1 { margin: 0; font-size: 15px; }
#scope { font-size: 12px; color: #8a8378; }
#notes {
  flex: 1;
  border: 0;
  resize: none;
  padding: 14px;
  font: inherit;
  outline: none;
}
footer {
  padding: 8px 14px;
  font-size: 12px;
  color: #8a8378;
  border-top: 1px solid #e5e2dc;
}

That's a complete, if plain, UI. It won't do anything yet — we haven't wired the open button or the saving. Next.

Step 3: open the panel on click

Here's the one part that surprises people: you don't wire the toolbar click in the manifest. Unlike the popup and options UIs, which you configure declaratively, the side panel's open-on-click behavior is set imperatively from the service worker with chrome.sidePanel.setPanelBehavior(). This is the exact line older tutorials get wrong, so verify it against the official chrome.sidePanel reference if you see conflicting advice.

service-worker.js

// Toolbar-icon click opens the panel. Set once, on install.
chrome.runtime.onInstalled.addListener(() => {
  chrome.sidePanel
    .setPanelBehavior({ openPanelOnActionClick: true })
    .catch((error) => console.error(error));
});

Setting it in onInstalled is enough — the behavior persists. (You can call it at the top level of the worker too; either works. I like onInstalled because it reads as a one-time setup.) This mirrors Google's official cookbook.sidepanel-global sample (source, Apache-2.0), just with error handling made explicit.

Load the extension now (Step "Load it and try it" below) and clicking the icon already opens an empty notepad that follows you across tabs. But close it and your text is gone — the panel document reloads. Let's fix persistence.

Step 4: make the notes survive

The panel is a managed page with its own lifecycle, separate from the service worker. State in a JavaScript variable dies when the panel unloads — the same lifecycle gotcha that trips people up all over MV3, which I dug into in why a disabled extension can't clean up after itself. The durable place to keep data is chrome.storage.local.

sidepanel.js

const notes = document.getElementById("notes");
const statusEl = document.getElementById("status");
const KEY = "notes:global";
 
// Load whatever we saved last time.
async function load() {
  const stored = await chrome.storage.local.get(KEY);
  notes.value = stored[KEY] ?? "";
}
 
// Save on every keystroke, debounced so we don't hammer storage.
let timer;
notes.addEventListener("input", () => {
  statusEl.textContent = "Saving…";
  clearTimeout(timer);
  timer = setTimeout(async () => {
    await chrome.storage.local.set({ [KEY]: notes.value });
    statusEl.textContent = "Saved";
  }, 300);
});
 
load();

The storage API does need its own permission — add "storage" to the manifest:

"permissions": ["sidePanel", "storage"],

The good news: storage is one of the quiet permissions — it triggers no warning string at install time, so it costs you nothing in user trust. Reload the extension, type something, close the panel, reopen it — your text is there. You've got a working global notepad.

Step 5: one notepad per website

A single shared note is fine, but the useful version keeps a separate note for each site — a scratchpad for GitHub, another for your docs, another for wherever. To do that the panel needs to know the current tab's origin and swap notes when you switch tabs.

Reading a tab's URL requires the tabs permission, so add it. Be honest about the cost: tabs does show the user a broader access warning, and — a detail Frisbie calls out — the activeTab permission that popups lean on does not apply to the side panel, so there's no free-on-click shortcut here. If you need the URL, you ask for tabs (or a host permission). Update the permissions array:

"permissions": ["sidePanel", "storage", "tabs"],

Then rewrite the panel script to key notes by origin and react to tab changes:

sidepanel.js (replacing Step 4)

const notes = document.getElementById("notes");
const statusEl = document.getElementById("status");
const scopeEl = document.getElementById("scope");
 
let currentKey = "notes:global";
 
async function activeOrigin() {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  if (!tab?.url) return null;
  try {
    return new URL(tab.url).origin;
  } catch {
    return null; // chrome:// pages, new-tab page, etc.
  }
}
 
// Point the editor at the note for whatever tab is active now.
async function syncToActiveTab() {
  const origin = await activeOrigin();
  currentKey = origin ? `notes:${origin}` : "notes:global";
  scopeEl.textContent = origin ? new URL(origin).hostname : "All sites";
  const stored = await chrome.storage.local.get(currentKey);
  notes.value = stored[currentKey] ?? "";
}
 
let timer;
notes.addEventListener("input", () => {
  // Capture the key NOW — if the user switches tabs during the 300 ms
  // debounce, currentKey moves on and the save would land under the
  // wrong site's key, silently overwriting that site's note.
  const keySnapshot = currentKey;
  statusEl.textContent = "Saving…";
  clearTimeout(timer);
  timer = setTimeout(async () => {
    await chrome.storage.local.set({ [keySnapshot]: notes.value });
    statusEl.textContent = "Saved";
  }, 300);
});
 
// Switch notes when the user changes tabs or the tab navigates.
chrome.tabs.onActivated.addListener(syncToActiveTab);
chrome.tabs.onUpdated.addListener((_id, info) => {
  if (info.url) syncToActiveTab();
});
 
syncToActiveTab();

Reload, open the panel, and browse. On github.com you get one note; flip to a docs site and the panel swaps to a different one, live. The panel never closed — it just re-pointed itself at the active tab's origin. That "stays open, updates with context" behavior is the whole reason the side panel exists.

Optional: only show the panel on some sites

Sometimes you don't want the panel available everywhere — only on the sites it's built for. chrome.sidePanel.setOptions() enables or disables the panel per tab, which you drive from the service worker on tab updates. This is Google's cookbook.sidepanel-site-specific pattern (source, Apache-2.0):

One framing note before you paste: this snippet extends your existing service-worker.js — merge it in below the onInstalled block from Step 3. Replace the whole file with just this and the panel loses openPanelOnActionClick, so clicking the icon will never open it.

// service-worker.js — enable the panel only on a chosen origin.
const ALLOWED = "https://github.com";
 
chrome.tabs.onUpdated.addListener(async (tabId, _info, tab) => {
  if (!tab.url) return;
  const enabled = new URL(tab.url).origin === ALLOWED;
  await chrome.sidePanel.setOptions({
    tabId,
    path: "sidepanel.html",
    enabled
  });
});

Now the panel is offered on GitHub and quietly unavailable elsewhere. Handy when your panel only makes sense in one place.

Load it and try it

  1. Open chrome://extensions.
  2. Flip on Developer mode (top-right).
  3. Click Load unpacked and pick your browsing-notepad folder.
  4. Click the extension's toolbar icon. The notepad docks on the right.
  5. Type a note, switch tabs — the panel stays open. On the per-site version (Step 5), the note changes with the site.

The Browsing Notepad extension loaded unpacked in chrome://extensions Load unpacked, and the extension shows up ready to pin.

The notepad side panel staying open next to a web page The panel persists as you switch tabs — and swaps notes per site. Note the hostname in the panel header.

If it doesn't work, check these first:

  • Clicking the icon does nothing. setPanelBehavior({ openPanelOnActionClick: true }) didn't run — reload the extension so onInstalled fires again, and confirm the service worker has no red error in chrome://extensions.
  • Notes never persist. You forgot to add "storage" to permissions (without it chrome.storage is undefined in the panel), you're saving to a variable instead of chrome.storage.local, or you renamed the storage key between saves.
  • Per-site notes don't switch. You forgot to add "tabs" to permissions, so tab.url comes back undefined and every origin collapses to the same key.

One thing that trips people up: closing the panel with the X in its header is the only action that fully unloads the page. Navigating away or switching panels just hides it while keeping its state in memory, closer to a suspended tab than a closed one. Don't rely on a "fresh load" every time the panel reappears.

Cross-browser note

The chrome.sidePanel API is Chromium-only — it works in Chrome and Edge, but not Firefox. Firefox implements the same idea through a different model: the sidebar_action manifest key and the browser.sidebarAction API. So a truly cross-browser build ships both, branching on which API is present. (Firefox's sidebar is actually the older, more mature of the two designs; Chrome only caught up with sidePanel in 2023.) If you're targeting the browser.* namespace for cross-browser code, I wrote up what changed when Chrome adopted it. Don't assume one API covers every browser — it doesn't.

Before you publish

Two habits that save pain later. First, keep permissions minimal: we shipped Step 4 on sidePanel alone and only added tabs when a feature demanded it. Reviewers and users both punish over-asking. Second, scan your own build before it goes live — the same static analysis the stores should do but often don't. Our CLI does it in one command:

npx @extenshi/cli scan ./browsing-notepad

It flags risky API usage, permission bloat, and known-bad patterns before a reviewer (or an attacker) finds them. You get 3 scans and 10 reads free, one-time; past that, prepaid credit packs cover the rest and never expire. It's the cheapest bug you'll ever fix — the one you catch before shipping.

Wrapping up

You went from an empty folder to a side panel that stays open across tabs and keeps per-site notes, using nothing but chrome.sidePanel, chrome.tabs, and chrome.storage.local. The pattern generalizes: swap the notepad for a reading queue, a live scraper output, a chat UI, a checklist — anything whose job outlives a single click belongs in a panel, not a popup.

Building something real on top of this? Once your extension is live, seeing how users actually engage with it — installs, retention, the reviews landing on your listing — is its own thing. Explore extension analytics → and claim your extension to get verified install and uninstall data alongside the security scan.

Sources

Further reading

📚 Building Browser Extensions, 2nd Edition by Matt Frisbie — Amazon | Apress. Chapter 7 covers every extension UI (popup, options, side panel, devtools) and when to reach for each.


This article is a hands-on tutorial based on the official Chrome Extensions documentation and Google's Apache-2.0 sample code. Code is provided as-is for educational purposes; verify API behavior against the current developer.chrome.com reference before shipping. If you believe anything here is inaccurate, contact [email protected] and we'll review and update.

Related Articles