| name | chrome-extension |
| description | Chrome extension structure, Manifest V3, the popup, content scripts, service workers, and storage. Use when creating an extension, editing manifest.json, adding a permission, building the popup UI, injecting a content script, or wiring message passing. Triggers on manifest.json with manifest_version, chrome.tabs, chrome.scripting, chrome.storage, chrome.runtime.onMessage, service-worker.js, popup.html, and loading an unpacked extension. |
| user-invocable | false |
Chrome Extension
Manifest V3, a popup styled in Catppuccin Mocha, and the minimum permissions the extension actually needs.
Manifest V2 is removed from Chrome, so V3 is the only target. This assumes unpacked or sideloaded distribution; a Web Store listing adds review requirements and a signing key that change the packaging step.
Layout
extension-root/
├── manifest.json # required
├── Makefile # builds the distributable zip
├── README.md
├── .github/
│ ├── assets/logo.png
│ └── workflows/release.yaml
├── icons/
│ ├── icon16.png # toolbar
│ ├── icon32.png # Windows taskbar
│ ├── icon48.png # the extensions page
│ └── icon128.png # install dialog and store listing
├── popup/
│ ├── popup.html
│ ├── popup.css
│ └── popup.js
├── content/
│ └── content.js # runs inside web pages
├── background/
│ └── service-worker.js
└── lib/ # optional shared code
└── utils.js
Popup, content, and background code stay in separate directories because they run in three different contexts with three different sets of available APIs, and a flat layout invites calling chrome.tabs from a content script where it does not exist.
Manifest
{
"manifest_version": 3,
"name": "[EXTENSION_NAME]",
"version": "1.0.0",
"description": "[Brief description of what the extension does]",
"icons": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"action": {
"default_popup": "popup/popup.html",
"default_icon": {
"16": "icons/icon16.png",
"32": "icons/icon32.png"
}
},
"permissions": ["activeTab", "storage", "scripting"],
"background": {
"service_worker": "background/service-worker.js"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content/content.js"],
"run_at": "document_idle"
}
]
}
Unused sections are deleted rather than left empty. A declared content script that does nothing still runs on every page the user visits.
version here is what the browser installs against and what the Makefile reads, so it is the single place a release number is bumped.
Permissions
Request the minimum that works. Every permission is shown to the user at install time, and a broad one on a small extension is the most common reason someone declines it.
| Permission | Grants |
|---|
activeTab | access to the current tab only when the user clicks the extension |
storage | chrome.storage for settings |
scripting | programmatic script injection |
tabs | tab URLs and metadata for every tab, at all times |
cookies | reading and writing cookies, with host permissions |
webRequest | observing network requests |
activeTab is preferred over tabs wherever it suffices, since it grants access to one tab at the moment of a user gesture rather than standing access to all of them.
host_permissions names specific domains when the extension only works on specific domains. <all_urls> asks for every site a person will ever visit.
Popup
The popup is a normal page in an extension context: it has the chrome.* APIs and no access to the page it was opened over, which is what content scripts and chrome.scripting are for.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>[EXTENSION_NAME]</title>
<link rel="stylesheet" href="popup.css">
</head>
<body>
<div class="container">
<header class="header">
<h1>[EXTENSION_NAME]</h1>
<p class="subtitle">Brief tagline here</p>
</header>
<main class="content">
<div class="section">
<label for=>Label
Primary Action
document.addEventListener('DOMContentLoaded', async () => {
const actionBtn = document.getElementById('action-btn');
const status = document.getElementById('status');
const showStatus = (message, type = 'info') => {
status.textContent = message;
status.className = `status ${type}`;
};
actionBtn.addEventListener('click', async () => {
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const results = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => document.title,
});
showStatus(`Page title: ${results[0].result}`, 'success');
} (error) {
(, );
}
});
});
The function passed to executeScript is serialized and evaluated in the page, so it closes over nothing from the popup. A variable referenced from the surrounding scope is undefined there rather than an error at the call site.
Every chrome.* call is wrapped in a try/catch, because a permission the user revoked and a tab that closed both reject rather than returning an error value.
Theme
Catppuccin Mocha is the default for a new extension. An extension that already has a palette keeps it.
:root {
--rosewater: #f5e0dc; --flamingo: #f2cdcd; --pink: #f5c2e7;
--mauve: #cba6f7; --red: #f38ba8; --maroon: #eba0ac;
--peach: #fab387; --yellow: #f9e2af; --green: #a6e3a1;
--teal: #94e2d5; --sky: #89dceb; --sapphire: #74c7ec;
--blue: #89b4fa; --lavender: #b4befe; --text: #cdd6f4;
--subtext1: #bac2de; --subtext0: #a6adc8; --overlay2: #9399b2;
--overlay1: #7f849c; --overlay0: #6c7086; --surface2: #585b70;
--surface1: #45475a; --surface0: #313244; --base: #1e1e2e;
--mantle: #181825; --crust: #11111b;
}
* { margin: 0; padding: ; : border-box; }
{
: -apple-system, BlinkMacSystemFont, , Roboto, sans-serif;
: ;
: ;
: (--crust);
: (--subtext0);
: ;
: ;
}
{ : ; }
{ : ; : ; : solid (--surface1); }
{ : ; : solid (--surface1); }
{ : flex; : ; : ; }
, {
: ;
: ;
: (--surface0);
: (--text);
: solid (--surface1);
: ;
}
, { : none; : (--blue); }
{
: ;
: ;
: ;
: (--surface0);
: (--text);
: solid (--surface1);
: ;
: pointer;
: background-color ;
}
{ : (--surface1); }
{ : (--blue); : (--crust); : none; : ; }
{ : (--sapphire); }
{ : ; : not-allowed; }
{ : ; : ; : ; : center; }
{ : none; }
{ : (--green); : (, , , ); }
{ : (--red); : (, , , ); }
{ : (--yellow); : (, , , ); }
{ : (--blue); : (, , , ); }
The popup has a min-width and a max-width because Chrome sizes it from its content, and an unconstrained popup jumps in width as its status text changes.
.status:empty { display: none; } keeps an empty status area from reserving space before anything has happened.
Content Scripts
A content script runs in the page's DOM but in an isolated JavaScript world, so it sees the page's elements and not its variables.
(function () {
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === 'getData') {
sendResponse({
success: true,
data: { title: document.title, url: window.location.href },
});
}
return true;
});
})();
return true keeps the message channel open for an asynchronous sendResponse. Without it the channel closes when the listener returns and the reply is dropped.
The IIFE wrapper keeps declarations out of the shared script scope, since several content scripts can run in one page.
Service Worker
The background service worker is event-driven and Chrome terminates it when idle, so nothing may be held in a module-level variable across events. State lives in chrome.storage.
chrome.runtime.onInstalled.addListener(() => {
chrome.storage.local.set({ settings: { enabled: true } });
});
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === 'someAction') {
handleAction(message.data)
.then((result) => sendResponse({ success: true, result }))
.catch((error) => sendResponse({ success: false, error: error.message }));
return true;
}
});
async function handleAction(data) {
return { processed: true };
}
onInstalled seeds defaults once, so the first run has settings before any UI reads them.
Storage
async function loadSettings() {
const { settings } = await chrome.storage.local.get('settings');
return settings ?? { enabled: true };
}
async function saveSettings(settings) {
await chrome.storage.local.set({ settings });
}
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'local' && changes.settings) {
applySettings(changes.settings.newValue);
}
});
chrome.storage.local is used rather than localStorage, because the service worker has no localStorage and the two contexts would otherwise disagree about the same setting.
The onChanged listener is what keeps an open popup in step with a change made elsewhere, rather than each context caching its own copy.
Icons
| Size | Shown in |
|---|
| 16x16 | the browser toolbar |
| 32x32 | the Windows taskbar |
| 48x48 | chrome://extensions |
| 128x128 | the install dialog and store listing |
All four are the same design scaled, in PNG with transparency, drawn from the Catppuccin palette so they read against both light and dark browser chrome. A design that only works at 128 pixels is unrecognizable at 16, which is the size a user actually looks at.