Skip to main content
Technology & EngineeringBrowser Extension344 lines

Popup UI

Building popup and options page UIs for browser extensions with responsive layouts and framework integration

Quick Summary11 lines
You are an expert in popup and options page UI for building browser extensions.

## Key Points

- Set an explicit `width` on the popup `body` to avoid layout shifts; popups auto-size to content but can appear jumpy without a fixed width.
- Load saved state in `DOMContentLoaded` so the UI reflects the current configuration immediately when opened.
- Keep popups lightweight — they are recreated every time the user clicks the icon, so avoid expensive initialization.
- **Using inline scripts in popup HTML** — extension CSP blocks inline `<script>` tags and `onclick` attributes; always use external `.js` files loaded via `<script src="...">`.
- Inline scripts in popup HTML are blocked by the extension Content Security Policy — always use separate `.js` files loaded via `<script src="...">`.
skilldb get browser-extension-skills/Popup UIFull skill: 344 lines
Paste into your CLAUDE.md or agent config

Popup UI — Browser Extension Development

You are an expert in popup and options page UI for building browser extensions.

Overview

Browser extensions use HTML pages for their user-facing interfaces: the popup (activated from the toolbar icon), options pages (for settings), and side panels. These pages run in the extension's privileged context and have access to all granted Chrome APIs.

Core Concepts

Popup Configuration

{
  "action": {
    "default_popup": "popup.html",
    "default_icon": {
      "16": "icons/icon16.png",
      "48": "icons/icon48.png"
    },
    "default_title": "Click to open"
  },
  "options_ui": {
    "page": "options.html",
    "open_in_tab": true
  }
}

Popup HTML Structure

Popups are standard HTML pages with constraints: they have a max width of 800px and max height of 600px, and they close when they lose focus.

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="stylesheet" href="popup.css">
</head>
<body>
  <div class="container">
    <header>
      <h1>My Extension</h1>
    </header>
    <main id="content">
      <div class="status" id="status">Loading...</div>
      <div class="controls">
        <label class="toggle">
          <input type="checkbox" id="enabled-toggle">
          <span>Enable feature</span>
        </label>
        <button id="action-btn" class="primary">Run Action</button>
      </div>
      <div id="results"></div>
    </main>
  </div>
  <script src="popup.js"></script>
</body>
</html>

Popup Styling

/* popup.css */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  width: 360px;
  min-height: 200px;
  font-family: system-ui, -apple-system, sans-serif;
  font-size: 14px;
  color: #1a1a1a;
  background: #ffffff;
}

.container {
  padding: 16px;
}

header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 12px;
  padding-bottom: 12px;
  border-bottom: 1px solid #e5e5e5;
}

header h1 {
  font-size: 16px;
  font-weight: 600;
}

.controls {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.toggle {
  display: flex;
  align-items: center;
  gap: 8px;
  cursor: pointer;
}

button.primary {
  padding: 8px 16px;
  background: #4285f4;
  color: white;
  border: none;
  border-radius: 6px;
  font-size: 14px;
  cursor: pointer;
}

button.primary:hover {
  background: #3367d6;
}

button.primary:disabled {
  background: #a0c4ff;
  cursor: not-allowed;
}

Popup JavaScript

// popup.js
document.addEventListener('DOMContentLoaded', async () => {
  const toggle = document.getElementById('enabled-toggle');
  const actionBtn = document.getElementById('action-btn');
  const status = document.getElementById('status');

  // Load saved state
  const { enabled = false } = await chrome.storage.local.get('enabled');
  toggle.checked = enabled;
  status.textContent = enabled ? 'Active' : 'Inactive';

  // Handle toggle
  toggle.addEventListener('change', async () => {
    const newState = toggle.checked;
    await chrome.storage.local.set({ enabled: newState });
    status.textContent = newState ? 'Active' : 'Inactive';

    // Notify background
    chrome.runtime.sendMessage({ type: 'TOGGLE_STATE', enabled: newState });
  });

  // Handle action button
  actionBtn.addEventListener('click', async () => {
    actionBtn.disabled = true;
    actionBtn.textContent = 'Running...';

    try {
      const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
      const response = await chrome.tabs.sendMessage(tab.id, { type: 'RUN_ACTION' });
      document.getElementById('results').textContent = response.result;
    } catch (err) {
      document.getElementById('results').textContent = 'Error: ' + err.message;
    } finally {
      actionBtn.disabled = false;
      actionBtn.textContent = 'Run Action';
    }
  });
});

Implementation Patterns

Options Page with Sections

<!-- options.html -->
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <link rel="stylesheet" href="options.css">
</head>
<body>
  <div class="options-container">
    <h1>Extension Settings</h1>

    <section>
      <h2>General</h2>
      <div class="setting-row">
        <label for="theme">Theme</label>
        <select id="theme">
          <option value="light">Light</option>
          <option value="dark">Dark</option>
          <option value="system">System</option>
        </select>
      </div>
      <div class="setting-row">
        <label for="language">Language</label>
        <select id="language">
          <option value="en">English</option>
          <option value="es">Spanish</option>
          <option value="fr">French</option>
        </select>
      </div>
    </section>

    <section>
      <h2>Advanced</h2>
      <div class="setting-row">
        <label>
          <input type="checkbox" id="debug-mode">
          Enable debug mode
        </label>
      </div>
    </section>

    <div class="actions">
      <button id="save-btn">Save Settings</button>
      <span id="save-status"></span>
    </div>
  </div>
  <script src="options.js"></script>
</body>
</html>
// options.js
const DEFAULTS = { theme: 'system', language: 'en', debugMode: false };

async function loadSettings() {
  const { settings = DEFAULTS } = await chrome.storage.sync.get('settings');
  document.getElementById('theme').value = settings.theme;
  document.getElementById('language').value = settings.language;
  document.getElementById('debug-mode').checked = settings.debugMode;
}

document.getElementById('save-btn').addEventListener('click', async () => {
  const settings = {
    theme: document.getElementById('theme').value,
    language: document.getElementById('language').value,
    debugMode: document.getElementById('debug-mode').checked
  };

  await chrome.storage.sync.set({ settings });

  const status = document.getElementById('save-status');
  status.textContent = 'Saved!';
  setTimeout(() => { status.textContent = ''; }, 2000);
});

document.addEventListener('DOMContentLoaded', loadSettings);

Using a Framework (React Example)

For complex UIs, frameworks work in extension popups. Build the output into the extension directory:

// vite.config.js for a React-based popup
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  build: {
    outDir: 'dist',
    rollupOptions: {
      input: {
        popup: 'src/popup/index.html',
        options: 'src/options/index.html'
      }
    }
  }
});

Dark Mode Support

@media (prefers-color-scheme: dark) {
  body {
    background: #1e1e1e;
    color: #e0e0e0;
  }
  header {
    border-bottom-color: #333;
  }
  button.primary {
    background: #5a9cf5;
  }
}

/* Or use a data attribute for manual toggle */
[data-theme="dark"] {
  --bg: #1e1e1e;
  --text: #e0e0e0;
  --border: #333;
}

Best Practices

  • Set an explicit width on the popup body to avoid layout shifts; popups auto-size to content but can appear jumpy without a fixed width.
  • Load saved state in DOMContentLoaded so the UI reflects the current configuration immediately when opened.
  • Keep popups lightweight — they are recreated every time the user clicks the icon, so avoid expensive initialization.

Core Philosophy

Popups are ephemeral by design. They are created fresh every time the user clicks the toolbar icon, and they are destroyed the instant focus is lost. This means a popup should be a thin view layer that reads state from storage on open and writes state back to storage on change. Never use the popup as a place to accumulate state or run long-lived processes — it will be killed without warning.

Speed is paramount in popup design. Users expect the popup to appear instantly and show relevant content within 100ms. Heavy initialization, large framework bundles, or network requests that block rendering make the popup feel broken. Load saved state from chrome.storage synchronously (it is fast), render immediately, and fetch fresh data in the background if needed.

Treat the popup as a remote control, not a full application. It should provide quick access to the extension's core actions — toggling features, showing status, launching actions on the current page. Complex workflows, settings pages, and data-heavy views belong in the options page or a dedicated tab, not crammed into a 360-pixel-wide popup that closes when the user sneezes.

Anti-Patterns

  • Storing critical state in popup JavaScript variables — any state held in memory is lost the instant the popup closes; always persist to chrome.storage for anything that needs to survive across opens.

  • Loading a full SPA framework for a simple popup — bundling React with a router, state management, and dozens of components for a popup that shows a toggle and a status label adds hundreds of kilobytes of unnecessary overhead; use vanilla JS or a lightweight framework.

  • Making blocking network requests before rendering — fetching data from an API before showing any UI makes the popup appear broken for the duration of the request; render a skeleton or cached state immediately and update when data arrives.

  • Using inline scripts in popup HTML — extension CSP blocks inline <script> tags and onclick attributes; always use external .js files loaded via <script src="...">.

  • Building complex multi-page flows inside the popup — navigating between views in a popup that can close at any moment leads to lost state and confused users; use chrome.tabs.create to open complex flows in a full tab instead.

Common Pitfalls

  • Popups close as soon as they lose focus (e.g., when the user clicks elsewhere), and all state in the popup's JavaScript is lost — always persist important state to chrome.storage rather than holding it in memory.
  • Inline scripts in popup HTML are blocked by the extension Content Security Policy — always use separate .js files loaded via <script src="...">.

Install this skill directly: skilldb add browser-extension-skills

Get CLI access →