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, plus the highlighter demo that proves they work.

Declarative (manifest) Programmatic (chrome.scripting) MAIN world
When it runs every matching page, automatically when your code calls executeScript page context, alongside page JS
Permissions host permissions listed at install scripting + host perms or activeTab (no install-time host warning) same as chosen injection + web_accessible_resources
JS isolation isolated world isolated world none; the page sees and can tamper with you
CSP extension’s extension’s the page’s; a strict page CSP can block you
Review impact broad match patterns increase review time activeTab is the review-friendliest highest scrutiny

Kit defaults:

  • Declarative + isolated world (entrypoints/content/) for features that work passively on matching sites. Keep matches as narrow as your product allows; broad patterns increase review time.
  • Programmatic + activeTab when the feature is user-invoked (toolbar click): access per click, no install-time warning.
  • MAIN world only 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 <html> font-size, so a html { font-size: 32px } page would double everything. The kit converts rem→px at build (PostCSS in wxt.config.ts) and the wrapper pins font-size: 16px.
  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 must never decide your theme) and exposes a useShadowContainer() portal target. Never portal overlays to document.body; they’d 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 */
});

Remount work belongs in this handler, not in URL polling.

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/marks via ignore so your DOM writes don’t re-trigger you (the infinite-loop guard).
  • 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 is an end-to-end proof of everything above, running on hostile pages. Trace it in apps/extension/components/content/Highlighter.tsx and apps/extension/utils/highlights.ts:

  1. Select text on any matching page → a shadow-UI button appears. It’s mounted with mountShadowUi, so a page with html { font-size: 32px } and * { all: revert } can’t distort it; the e2e suite asserts exactly 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 (gateAction: highlight). After enough actions, the paywall raises on the page itself for free users.
  4. SPA navigation and DOM mutations re-anchor highlights via an idempotent applyAll() driven by wxt:locationchange and observeDom, with no duplicates.
  5. Click a highlight to remove it for good.

When building 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.