On-page UI
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 status card that proves they work.
The status card is the kit’s on-page demo (module on-page): a content
script on every https:// page. It is the extension’s only access to
websites, so a product without on-page UI removes it. The wizard’s
On-page UI question does it on the first run, and on any later one:
commit, then
pnpm create extstart --on-page no --yesdeletes entrypoints/content/, components/content/ and their e2e
spec, and the manifest asks for no host access. Everything below still
applies to a content script you add (/add-surface).
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/<name>.content/) for features that work passively on matching sites. Keepmatchesas narrow as your product allows, and spreadOWN_PAGES(utils/own-pages.ts) intoexcludeMatches: it keeps the extension off the pages it opens itself (Google’s account chooser, the checkout of Stripe and of the provider your clone kept, your Hosting or Supabase pages, your welcome page), so a paywall never covers a sign-in or a payment. The demo needs billing (its UI is the status card and the page’s walls), so a free extension ships without it. - Programmatic +
activeTabwhen 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 rawpostMessagelistener.
Executed JS/WASM must ship in the bundle; remote scripts are an instant rejection. Remote JSON/CSS data is fine.
Read the current page from the popup
Section titled “Read the current page from the popup”A feature the user starts from the popup (“summarize this page”) reads
the tab with activeTab and scripting. Opening the popup grants the
extension that one tab: no install-time warning, and no access to any
other tab. Add both permissions with /add-permission, in your
feature’s own module.
function readPage() { // Runs inside the page. return { title: document.title, text: document.body.innerText };}
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });if (tab?.id === undefined) return;const [injection] = await browser.scripting.executeScript({ target: { tabId: tab.id }, func: readPage,});const page = injection?.result;readPageis sent to the page as source text. It can’t import anything or use variables from outside itself, it returns plain JSON data, and it sticks to plain syntax: object spread and the like get a bundler helper that doesn’t travel with the source.- The browser refuses its own pages:
chrome://pages, the new tab page, the Chrome Web Store, the PDF viewer, other extensions’ pages.executeScriptthen rejects; catch it and tell the user the page can’t be read. - Selected text:
window.getSelection()misses a selection inside a text field, so readselectionStartandselectionEndof a focused<textarea>or text<input>first. Skip a focused password field (type="password"): Chrome reports its bullets as the selection. - With a paywall, read the page first and call
accessafter, so a page that can’t be read never spends a free use. - Keep the code that turns the page into your output in a plain function. Unit tests run without a DOM and can call it.
- Playwright can’t click the toolbar, so end-to-end tests never get
activeTab. StubexecuteScriptin the popup so it runs the function the popup passes, as built, in a test page; a refused page is a realchrome://tab withexecuteScriptleft alone. The/add-featurerecipe has the code for both, and for recording clipboard writes, which Playwright can’t read back from an extension page. - Say on your privacy page what the feature reads and where it goes.
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 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 root font size, so a
html { font-size: 32px }page would double everything. The kit converts rem to px at build and pinsfont-size: 16pxon the wrapper. - CSS custom properties inherit across the shadow boundary. The kit’s tokens are defined inside the shadow root (on its host, the dark values on the wrapper), so same-named page variables lose; never read page-defined variables.
@font-face/@propertymust live in the top document. WXT moves them out of the shadow stylesheet into the page when it mounts the UI.
The wrapper also applies class-strategy dark mode from the settings
store (the host page’s classes never decide your theme). The
render(container) callback receives the wrapper element — pass it to
Toaster/overlay container props. Never portal overlays to
document.body; they would land outside the shadow styles.
The host itself sits in the browser’s top layer (the Popover API), above
every z-index a site can set, chat widgets and cookie banners at
2147483647 included. Browsers without the API get the host at that
maximum z-index instead, from apps/extension/assets/shadow-host.css.
The host is a 0 by 0 box with no layout of its own, so position what you
render with fixed, as the status card does.
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.
Inline UI: the other mounting mode
Section titled “Inline UI: the other mounting mode”Shadow UI floats above the page. Inline UI sits inside it: an element in the page’s own layout that inherits the page’s fonts and colors, for affordances that should feel native to the content.
- Shadow (
mountShadowUi): the status card and the walls. Host styles cannot touch them. - Inline (WXT’s
createIntegratedUiwithposition: "inline"and ananchorselector): a badge after a heading, a button inside a toolbar. The page’s CSS applies to it by design, so set every style it needs explicitly and mount it from the samemain(ctx)as the shadow UI.
Pick per feature, not per product: shadow for panels the page must not restyle, inline for elements that belong to the page’s flow.
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 */});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 via
ignoreso 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.
Walkthrough: the status card
Section titled “Walkthrough: the status card”The card in the bottom-right corner of every matching page is the kit’s
on-page proof and the seed for your own feature. Trace it in
apps/extension/entrypoints/content/index.tsx and
apps/extension/components/content/StatusCard.tsx:
- It mounts with
mountShadowUi, next to the paywall overlay and the toaster, in one shadow root with one theme watcher. A page withhtml { font-size: 32px }and* { all: revert }can’t distort it, and a layer the page pins at the maximumz-indexcan’t cover it; the e2e suite asserts both. - Plan state streams from storage: the background is the single writer of the plan and per-feature access, and the card only reads.
- The buttons are the demo card’s two actions: each asks the
paywall engine with
sendMessage("access", { feature })before any work, foryour-free-actionandpremium-demo. They show the same state as the popup for any recipe: an allowance counts down inside its button, a Pro action carries the lock. The use that spends the allowance, and a click on a locked action, raise the wall in this same shadow mount; a signed-out user is asked to sign in first. - The close control hides the card for this page view.
For your real product: put your feature’s on-page work behind the card’s
access calls, or replace the card with your own component in the same
mount, and narrow matches. Without on-page UI, remove the demo
(--on-page no, above). Keep the pattern: mount with mountShadowUi, react to
wxt:locationchange, observe with observeDom, write DOM with DOM APIs.