Using Web Components in Extension Popups
Build extension popup and options UI with native web components: custom elements without a framework, shadow DOM styling with shared tokens, adoptedStyleSheets, form-associated elements, reusing components in content scripts, and the custom elements limitation in isolated worlds.
Table of Contents
An extension’s popup is small, opens dozens of times a day, and must render in a few milliseconds. A framework like React adds tens of kilobytes and a hydration step; plain DOM code becomes unmaintainable past a few screens. Native web components sit in between: custom elements give you reusable, encapsulated UI with no dependency and no build step required, they work identically in the popup, options page and side panel, and their shadow DOM keeps styles contained. There is one extension-specific trap — custom elements are not available in content scripts’ isolated worlds in Chrome — which shapes how you reuse them on web pages. This guide builds components for extension pages and handles that trap. It belongs to popup interface design.
Why web components suit extension pages
Extension pages are modern Chromium, Firefox or Safari documents, so custom elements, shadow DOM, adoptedStyleSheets, template elements and form-associated custom elements are all available without polyfills. A component is a class registered with customElements.define, used as an HTML tag, and it can read chrome.storage and chrome.i18n directly. Shadow DOM isolates component styles from each other, while CSS custom properties pass through the shadow boundary — so a shared token stylesheet themes every component. Startup cost is near zero: no framework runtime, and components upgrade as soon as their definitions load.
Step-by-step: components for extension UI
1. Share one base stylesheet across shadow roots
1// ui/base-sheet.js
2export const baseSheet = new CSSStyleSheet();
3baseSheet.replaceSync(`
4 :host { font: 13px/1.4 system-ui, sans-serif; color: var(--text); }
5 button { font: inherit; color: inherit; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 4px 10px; }
6 button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
7 .muted { color: var(--muted); }
8`);
Execution context: extension pages. A constructable stylesheet is parsed once and adopted by many shadow roots without duplication. Tokens like --text are defined on :root by the page’s token stylesheet, and custom properties inherit through shadow boundaries, so dark mode works everywhere. See theming the options page for dark mode.
2. Write a component
1// ui/item-row.js
2import { baseSheet } from "./base-sheet.js";
3
4const sheet = new CSSStyleSheet();
5sheet.replaceSync(`
6 :host { display: flex; gap: 8px; align-items: center; padding: 6px 8px; }
7 :host([archived]) { opacity: .6; }
8 .title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
9`);
10
11class ItemRow extends HTMLElement {
12 static observedAttributes = ["title", "href", "archived"];
13 #root = this.attachShadow({ mode: "open" });
14 constructor() {
15 super();
16 this.#root.adoptedStyleSheets = [baseSheet, sheet];
17 this.#root.innerHTML = `<span class="title"></span><button part="archive" type="button"></button>`;
18 this.#root.querySelector("button").addEventListener("click", () =>
19 this.dispatchEvent(new CustomEvent("archive", { bubbles: true, composed: true, detail: { id: this.dataset.id } })));
20 }
21 attributeChangedCallback() {
22 this.#root.querySelector(".title").textContent = this.getAttribute("title") ?? "";
23 const btn = this.#root.querySelector("button");
24 btn.textContent = chrome.i18n.getMessage(this.hasAttribute("archived") ? "unarchive" : "archive");
25 btn.setAttribute("aria-label", `${btn.textContent}: ${this.getAttribute("title") ?? ""}`);
26 }
27}
28customElements.define("item-row", ItemRow);
Execution context: extension pages. The static innerHTML contains no data; all data is set with textContent, so titles from web pages cannot inject markup. The component emits a composed custom event so the popup can handle archiving without the component knowing about the service worker. part="archive" lets the page style the button from outside if needed. An aria-label that includes the title makes each row’s button distinct for screen readers.
3. Compose components in the popup
1// popup.js
2import "./ui/item-row.js";
3const list = document.querySelector("#items");
4const { items = [] } = await chrome.storage.local.get("items");
5list.replaceChildren(...items.map((it) => {
6 const row = document.createElement("item-row");
7 row.dataset.id = it.id;
8 row.setAttribute("title", it.title);
9 if (it.archived) row.setAttribute("archived", "");
10 return row;
11}));
12list.addEventListener("archive", (e) => chrome.runtime.sendMessage({ type: "archive", id: e.detail.id }));
Execution context: the popup. Data flows in as attributes and out as events — the same contract a framework would use, with no library. Event delegation on the list handles every row. For very long lists, combine with the virtualisation in rendering long lists in a popup.
4. Make settings controls form-associated
1class ToggleSetting extends HTMLElement {
2 static formAssociated = true;
3 #internals = this.attachInternals();
4 #root = this.attachShadow({ mode: "open", delegatesFocus: true });
5 connectedCallback() {
6 this.#root.adoptedStyleSheets = [baseSheet];
7 this.#root.innerHTML = `<label><input type="checkbox"><span></span></label>`;
8 const input = this.#root.querySelector("input");
9 this.#root.querySelector("span").textContent = chrome.i18n.getMessage(this.getAttribute("label-key"));
10 const key = this.getAttribute("name");
11 chrome.storage.sync.get(key).then((r) => { input.checked = Boolean(r[key]); this.#internals.setFormValue(String(input.checked)); });
12 input.addEventListener("change", () => { chrome.storage.sync.set({ [key]: input.checked }); this.#internals.setFormValue(String(input.checked)); });
13 }
14}
15customElements.define("toggle-setting", ToggleSetting);
Execution context: options page or popup. formAssociated with ElementInternals lets the component participate in native forms (FormData, reset). delegatesFocus forwards focus to the inner checkbox so keyboard navigation and labels behave natively. The component binds itself to a storage key, making <toggle-setting name="showBadge" label-key="showBadge"> a complete, auto-saving setting.
5. Handle content scripts’ missing custom elements
1// content script — customElements is null in Chrome's isolated world
2export function mountItemRow(container, item) {
3 const host = document.createElement("div");
4 const root = host.attachShadow({ mode: "closed" });
5 root.adoptedStyleSheets = [baseSheet, itemRowSheet]; // reuse the same CSS
6 root.innerHTML = `<span class="title"></span><button type="button"></button>`;
7 root.querySelector(".title").textContent = item.title;
8 container.append(host);
9 return host;
10}
Execution context: a content script. In Chrome, window.customElements is null in the isolated world, so customElements.define throws. Options: render the same markup and stylesheets into a plain shadow root (as here), keeping visual consistency; or host the component-based UI in an extension iframe injected into the page, which is a full extension document; avoid defining elements in the page’s main world, where the page can interfere. See avoiding CSS conflicts between extension and page.
6. Load components early
Import component modules in the popup’s entry script before rendering, or include <script type="module"> tags in <head>. Elements in the HTML before definitions load render as unknown elements and then upgrade — invisible for a fast-loading popup, but avoid layout shift with :not(:defined) { visibility: hidden; } if needed.
7. Test components in a real browser
Web components need a DOM with shadow DOM support; JSDOM supports custom elements and shadow roots well enough for logic tests, but run visual and interaction tests in a browser (Playwright or Web Test Runner). Fake chrome.storage and chrome.i18n as for any extension page test.
Common mistakes
- Defining custom elements in a Chrome content script.
customElementsis null there. innerHTMLwith data. UsetextContentfor untrusted values.- Duplicated
<style>per instance. Use shared constructable stylesheets. - Events without
composed. They stop at the shadow boundary. - No
delegatesFocuson wrapper controls. Focus and labels misbehave.
Cross-browser variation
- Chrome / Edge: full support in extension pages;
customElementsis null in content-script isolated worlds. - Firefox: full support in extension pages; isolated-world content scripts can define custom elements with caveats about cross-compartment access.
- Safari: supports autonomous custom elements, shadow DOM and constructable stylesheets in current versions; customised built-ins (
is="…") are not supported — use autonomous elements only.
Verification
- Open the popup and confirm components render with tokens in light and dark themes.
- Tab through rows and confirm focus moves into shadow buttons with visible outlines.
- Archive via a component and confirm the event reaches the popup handler.
- Mount the content-script variant on a page and confirm no
customElementserrors.
FAQ
Can I use Lit or another small library?
Yes. Lit adds a few kilobytes and convenient templating while keeping native components.
Do web components work in the side panel?
Yes — it is an extension page like the popup.
How do I share components between popup and options?
Import the same modules in both entries; the definitions run once per page.
Should I use open or closed shadow roots?
Open roots in extension pages make testing and debugging easier and carry no risk, since no untrusted script runs there. Use closed roots for UI injected into web pages, where page scripts could otherwise reach in.
Related
- Rendering long lists in a popup — large data.
- Building a floating action button on web pages — shadow DOM on pages.
- Building an options page with React — the framework alternative.
- Popup interface design — the parent topic.