Skip to content

UI on web pages

Your UI on other people’s pages runs beside code you don’t control (Chrome calls this a content script). This guide covers the kit’s survival utilities and the highlighter demo that proves they work.

Declarative (manifest)Programmatic (chrome.scripting)MAIN world
When it runsevery matching page, automaticallywhen your code calls executeScriptpage context, alongside page JS
Permissionshost permissions listed at installscripting + host perms or activeTab (no install-time host warning)same as chosen injection + web_accessible_resources
JS isolationisolated worldisolated worldnone; the page sees and can tamper with you
CSPextension’sextension’sthe page’s; a strict page CSP can block you
Review impactbroad match patterns increase review timeactiveTab is the review-friendliesthighest scrutiny

Kit defaults:

  • Declarative + isolated world (entrypoints/content/) for features that work passively on matching sites. Keep matches as narrow as your product allows.
  • Programmatic + activeTab when the feature is user-invoked (toolbar click): access per click, no install-time warning.
  • MAIN world as a last resort (reading page JS state, patching page APIs): you forfeit isolation and run under the page’s CSP. Keep the MAIN-world part tiny and message back through utils/page-bridge.ts, which enforces origin, source, and schema checks; never hand-roll a raw postMessage listener.

Executed JS/WASM must ship in the bundle; remote scripts are an instant rejection. Remote JSON/CSS data is fine.

Mount shadow-DOM UI via mountShadowUi (apps/extension/utils/shadow-ui.tsx), never raw createShadowRootUi:

await mountShadowUi(ctx, {
name: "my-feature-ui", // custom-element tag
position: "overlay", // "inline" | "overlay" | "modal"
render: () => <MyFeature />,
});

WXT’s shadow root gives :host { all: initial } isolation, but three vectors still pierce it. The wrapper handles all three:

  1. rem units resolve against the host page’s root font size, so a html { font-size: 32px } page would double everything. The kit converts rem to px at build and pins font-size: 16px on the wrapper.
  2. CSS custom properties inherit across the shadow boundary. The kit’s tokens are defined on the wrapper so same-named page variables lose; never read page-defined variables.
  3. @font-face / @property must live in the top document. WXT hoists them out of the shadow stylesheet at build.

The wrapper also applies class-strategy dark mode from the settings store (the host page’s classes never decide your theme) and exposes a useShadowContainer() portal target. Never portal overlays to document.body; they would land outside the shadow styles.

For complex editors that need full event isolation (keyboard shortcuts, focus), use WXT’s iframe mode (createIframeUi); you pay with an extra document and messaging.

Never monkey-patch history.pushState. Listen instead:

ctx.addEventListener(window, "wxt:locationchange", ({ newUrl }) => {
/* re-run idempotent mount/apply work here */
});

observeDom(ctx, callback, options) (apps/extension/utils/observe.ts) wraps MutationObserver with the three rules that keep observers from melting busy pages:

  • Debounced batches (default 250 ms): bursts collapse into one trailing callback.
  • Self-mutation guard: pass your own shadow hosts via ignore so your DOM writes don’t re-trigger you.
  • Disconnect on invalidation: auto-disconnects when the extension updates or reloads while the tab lives on.

Make the callback idempotent and cheap. Anything heavy belongs behind the debounce or in the background.

The content-demo module exercises everything above on hostile pages. Trace it in apps/extension/components/content/Highlighter.tsx and apps/extension/utils/highlights.ts:

  1. Select text on a matching page: a shadow-UI button appears, mounted with mountShadowUi. A page with html { font-size: 32px } and * { all: revert } can’t distort it; the e2e suite asserts that.
  2. Click Highlight: the selection is wrapped using DOM APIs only, never innerHTML (lint-banned kit-wide).
  3. The highlight persists per page in storage.local.highlights and counts as a gate action; after enough actions the paywall raises on the page itself.
  4. SPA navigation and DOM mutations re-anchor highlights via an idempotent applyAll() driven by wxt:locationchange and observeDom.
  5. Click a highlight to remove it.

For your real product: drop the content-demo module and keep the pattern. Mount with mountShadowUi, react to wxt:locationchange, observe with observeDom, write DOM with DOM APIs.