Skip to content
Get the kit

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

Terminal window
pnpm create extstart --on-page no --yes

deletes 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).

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/<name>.content/) for features that work passively on matching sites. Keep matches as narrow as your product allows, and spread OWN_PAGES (utils/own-pages.ts) into excludeMatches: 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 + 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.

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.

apps/extension/components/MyFeature.tsx
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;
  • readPage is 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. executeScript then 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 read selectionStart and selectionEnd of 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 access after, 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. Stub executeScript in the popup so it runs the function the popup passes, as built, in a test page; a refused page is a real chrome:// tab with executeScript left alone. The /add-feature recipe 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 shadow-DOM UI via mountShadowUi (apps/extension/utils/shadow-ui.tsx), never raw createShadowRootUi:

apps/extension/entrypoints/content/index.tsx
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:

  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 inside the shadow root (on its host, the dark values 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 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.

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 createIntegratedUi with position: "inline" and an anchor selector): 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 same main(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.

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 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:

  1. It mounts with mountShadowUi, next to the paywall overlay and the toaster, in one shadow root with one theme watcher. A page with html { font-size: 32px } and * { all: revert } can’t distort it, and a layer the page pins at the maximum z-index can’t cover it; the e2e suite asserts both.
  2. Plan state streams from storage: the background is the single writer of the plan and per-feature access, and the card only reads.
  3. The buttons are the demo card’s two actions: each asks the paywall engine with sendMessage("access", { feature }) before any work, for your-free-action and premium-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.
  4. 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.