C.W.K.
Stream
Lesson 03 of 06 · published

Panel vs Popup vs Full Tab — Picking the Right Surface

~10 min · side-panel, popup, tabs.create, ux, architecture

Level 0Extension Curious
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Three surfaces, three lifetimes, three information densities. Lesson 3 is the decision matrix — which one earns each ClipDeck feature, and why pushing the wrong feature into the wrong surface is the most common extension UX bug."

The Three Surfaces

  • Popup. Tiny, transient. Max ~800×600 pixels, but reasonable size is more like 320×480. Dies on any outside click. Mounted at toolbar-icon click, unmounted the moment focus leaves. Excellent for one-off interactions — settings toggle, quick status, single-button action.
  • Side panel. Tall, persistent. Full window height, ~300–400 px wide depending on user preference. Survives clicks on the page; survives tab switches; dies only when the user explicitly closes it. Excellent for ambient context the user references repeatedly.
  • Full tab. The biggest. Open with chrome.tabs.create({ url: 'options.html' }) or via chrome_url_overrides. Same context type as a regular web page; can take the whole window. Excellent for dedicated apps, settings pages, onboarding flows, complex multi-step UI.

Lifetime, Compared

SurfaceOpen untilLoses focus onRe-mount cost
Popupany outside clickeverythingload + render every open
Side paneluser toggles offnothingload on first open, persist after
Full tabuser closes tabnothingload on first open, persist after

Popup mounting is cheap but happens often; panel and full-tab mounting is more expensive but happens rarely. This is the main throughput-vs-latency trade.

The ClipDeck Allocation

  • Popup — quick actions and stats. "Save current selection" button (which messages the SW to do the actual capture), "Open clip list" button (which opens the side panel), today's count, "Reset counters". Anything that completes in one click and does not need to remain visible.
  • Side panel — the clip library. Full list with timestamps, source URLs, search box, filters, inline edit/delete (Track 7). The user keeps it open while reading articles.
  • Full tab — reserved for the eventual options page (Track 6 introduces it) and a possible export/import view that needs more room than the side panel offers. ClipDeck v1 ships without a full-tab UI; v2 might.

The decision rule, stated as a question: how long does the user need to see this UI? Seconds → popup. Minutes-to-session → side panel. Long-running task or settings → full tab.

Anti-Patterns

  • Cramming a clip library into the popup. Past ten entries the popup feels broken. Symptoms: forced scroll, lost selection on each open, no room for filters. Push to the side panel.
  • Putting one-click actions in the side panel. Forces the user to open the panel just to dismiss it. Symptoms: low feature engagement. Push to the popup.
  • Building settings inside the popup. Multi-section forms need real estate. Symptoms: tiny inputs, popup auto-close on form interaction. Push to the full tab via options_page.
  • Opening a full tab for a quick status check. Disruptive — pulls the user out of the page they were on. Symptoms: user uninstalls. Push to the popup or panel.
Match the surface to the lifetime of the user's intent. Seconds = popup, minutes = panel, sessions = full tab. Wrong surface is the loudest UX bug an extension can ship.
Can the surfaces share code? Yes — popup, side panel, and full-tab pages can all import the same JS modules and share styles. ClipDeck's clip-row renderer can live in a single clip-row.js imported by panel.js and a future export-page.js. The popup typically has different needs (status-only), but if you find your popup and panel growing parallel render logic, factor it into a shared module rather than duplicating.

Code

background.js — open a full-tab options page·javascript
// background.js — open a dedicated full-tab page (for settings/export later)
async function openOptionsTab() {
  // chrome.runtime.openOptionsPage uses the options_page from manifest if set,
  // otherwise falls back to opening manifest.json's options_ui declarations.
  if (chrome.runtime.openOptionsPage) {
    await chrome.runtime.openOptionsPage();
  } else {
    await chrome.tabs.create({ url: chrome.runtime.getURL("options.html") });
  }
}

// Manifest must declare an options page:
//   "options_page": "options.html"
// — or use the richer:
//   "options_ui": { "page": "options.html", "open_in_tab": true }
popup.js — popup as quick status + entry point, not as library·javascript
// popup.js — minimal popup that delegates the library view to the side panel
async function quickStats() {
  const { clips = [], totalVisits = 0 } = await chrome.storage.local.get([
    "clips",
    "totalVisits",
  ]);
  document.getElementById("clipCount").textContent = String(clips.length);
  document.getElementById("visitCount").textContent = String(totalVisits);
}

document.getElementById("openPanelBtn").addEventListener("click", async () => {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  if (tab?.id) await chrome.sidePanel.open({ tabId: tab.id });
});

quickStats();
panel.js — library view stays in the persistent surface·javascript
// panel.js — full library lives here
// (Renders all clips with source title, timestamp, and source URL.)
async function render() {
  const { clips = [] } = await chrome.storage.local.get("clips");
  const root = document.getElementById("list");
  root.innerHTML = clips.length === 0 ? "<p class='empty'>No clips yet.</p>" : "";
  for (const clip of clips) {
    const row = document.createElement("div");
    row.className = "row";
    row.innerHTML = `
      <div class="meta">
        <a href="${clip.url}" target="_blank">${escapeHtml(clip.title || clip.url)}</a>
        <time>${new Date(clip.savedAt).toLocaleString()}</time>
      </div>
      <p>${escapeHtml(clip.text)}</p>`;
    root.appendChild(row);
  }
}

function escapeHtml(s) {
  return String(s).replace(/[&<>"']/g, (ch) => ({
    "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
  })[ch]);
}

chrome.storage.onChanged.addListener((c, a) => a === "local" && "clips" in c && render());
render();

External links

Exercise

Refactor clipdeck/popup.html to show only Clips: <span id="clipCount"></span>, Visits: <span id="visitCount"></span>, the existing Reset and Ping buttons, and the new Open Clip List button. Move the clip list rendering into clipdeck/panel.js using the third code block. Reload. Click the toolbar icon — popup is now tiny, just stats and actions. Click Open Clip List — panel takes over for browsing. Save a few clips and switch tabs; the panel content updates via onChanged. Bonus: add "options_page": "options.html" to manifest and a placeholder options.html with <h1>ClipDeck Settings (coming Track 6)</h1>, then from the popup add a chrome.runtime.openOptionsPage button — confirm Chrome opens a real tab.
Hint
If the popup looks cramped after the refactor, set explicit width: 280px; padding: 12px; on body — popups auto-size to content but have a max width. If the panel does not update when you save clips, double-check the panel.js subscribes to chrome.storage.onChanged AND filters on area === 'local'. The openOptionsPage button often works without manifest changes in modern Chrome (which falls back to a generic placeholder page), but the proper UX requires the explicit options_page declaration so users can also find it via chrome://extensions → ClipDeck → Details → Extension options.

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.