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.
Choose an injection strategy
Section titled “Choose an injection strategy”| 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. Keepmatchesas narrow as your product allows; broad patterns increase review time. - Programmatic +
activeTabwhen 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 rawpostMessagelistener.
Executed JS/WASM must ship in the bundle; remote scripts are an instant rejection. Remote JSON/CSS data is fine.
Mount UI with mountShadowUi
Section titled “Mount UI with mountShadowUi”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:
- rem units resolve against the HOST page’s
<html>font-size, so ahtml { font-size: 32px }page would double everything. The kit converts rem→px at build (PostCSS inwxt.config.ts) and the wrapper pinsfont-size: 16px. - 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.
@font-face/@propertymust 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.
Handle SPA navigation
Section titled “Handle SPA navigation”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.
Observe the DOM
Section titled “Observe the DOM”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
ignoreso 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.
Walkthrough: the highlighter demo
Section titled “Walkthrough: the highlighter demo”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:
- Select text on any matching page → a shadow-UI button appears. It’s mounted with
mountShadowUi, so a page withhtml { font-size: 32px }and* { all: revert }can’t distort it; the e2e suite asserts exactly that. - Click Highlight → the selection is wrapped using DOM APIs only, never
innerHTML(lint-banned kit-wide). - The highlight persists per page in
storage.local.highlightsand counts as a gate action (gateAction: highlight). After enough actions, the paywall raises on the page itself for free users. - SPA navigation and DOM mutations re-anchor highlights via an idempotent
applyAll()driven bywxt:locationchangeandobserveDom, with no duplicates. - 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.
