A production-ready boilerplate for building browser extension user interfaces. Write your UI once and deploy it consistently across popups, side panels, options pages, standalone tabs, and injected shadow DOM environments.
It guarantees comprehensive CSS isolation: your extension styles will not affect the host page, and the host page's CSS will not interfere with your extension.
Technology Stack: WXT 0.20, @webext-core (messaging and proxy-service), Mantine 9.3 (native CSS variables, no Emotion dependency), Tailwind 4 (strictly namespaced), and CSS Modules.
- Write the UI once, mount it anywhere — one component tree renders in the popup, side panel, options page, a standalone tab, or injected into any web page.
- Real shadow-DOM isolation, not hope — the known style-penetration vectors are handled and locked down by
pnpm audit:css. - Builds Chrome will actually load — an ASCII-output plugin for the Vite 8 / rolldown path, guarded by
pnpm audit:ascii(wxt#353). - Mantine v9 on native CSS variables — no Emotion runtime; color scheme, direction, and primary color stay in sync across every surface.
- Typed, tested, documented — TypeScript, vitest unit tests, and multilingual docs out of the box.
Note
This is a foundational template rather than an exhaustive framework. The code within src/ addresses the intricate plumbing inherent to browser extension development. Application-specific logic—such as authentication flows, HTTP/SSE clients, and complex build environments—is deliberately omitted. Please refer to the Patterns section for guidance on these implementations.
pnpm install
pnpm dev # Chrome: launches a development browser with the extension loaded
pnpm dev:firefox # Firefoxpnpm dev also initializes a local test host page at http://localhost:8787 for evaluating in-page injected UIs (executing python3 -m http.server -d test-pages 8787 internally).
- Configure the Namespace:
PROJECT_PREFIXhas a single source of truth inprefix.cjs(repo root). Change that one line andsrc/constants,postcss.config.cjs, andscripts/audit-css-vars.mjsall follow it automatically. The only place you must mirror it by hand isglobal.css(CSS can't import the constant) — update the.aie-surface-rootselectors and--aie-z-*variables there to match. Forgetting that mirror is caught loudly:pnpm testfails andpnpm dev/pnpm buildwarn or hard-fail via thecheck-prefix-syncVite plugin. This prefix namespaces your CSS variables and the core surface-root class to prevent conflicts with the host page. - Define the Manifest: Adjust the
nameandpermissionswithin themanifestfunction inwxt.config.ts. Target Manifest V3 (MV3); WXT automatically manages the down-conversion for Firefox. - Pin the Extension ID (Optional): Copy
.env.exampleto.envand set theWXT_EXTENSION_KEY. This securely injects your manifestkeyvia environment variables. Only variables prefixed withWXT_orVITE_are included in the build output. Always maintain secrets in.env, not in committed configuration files. - Add UI Surfaces: Every entry point under
src/entrypoints/mounts a React application fromsrc/react-app/apps/. To introduce a new surface, duplicate an existing app directory and configure the corresponding entry point. - Scope the Content Script: The demo content script matches every
http/httpspage (matchesinsrc/entrypoints/content/index.tsx) and — since content scripts cannot be code-split — ships the full Mantine + React bundle (~550 kB JS + ~260 kB CSS) into each of them. Fine for a demo; for a real product, narrowmatchesto the sites you actually target, or keep the broad match but defer mounting (dynamic-import the UI on user action). Broad host access also draws extra scrutiny in store review.
pnpm compile && pnpm lint && pnpm build
pnpm test # vitest: evaluates lifecycle, storage, and logic invariants (happy-dom + fake-browser)
pnpm audit:css # Validates CSS variable scoping (recommended after updating Tailwind/Mantine)
pnpm audit:ascii # Asserts build artifacts are pure ASCII (prevents Chrome CJK/UTF-8 load-time rejections)
# Both audits have `:firefox` variants that check the `pnpm build:firefox` output instead
pnpm zip # Packages a zip archive for the Chrome Web Store (`pnpm zip:firefox` for AMO)src/
├── entrypoints/ # WXT entry points (auto-discovered) — lightweight mount layers
│ ├── background.ts
│ ├── popup/ # index.html + main.tsx (similar for options/, sidepanel/, tab/)
│ └── content/ # in-page content script (index.tsx)
├── react-app/ # Core application logic
│ ├── apps/ # Individual applications per entry point
│ ├── components/ # Shared UI components
│ ├── managers/ # VisualManager (theme synchronization), Mantine registry
│ ├── hooks/ # Extension-specific hooks (e.g., useExtStorage)
│ └── styles/ # global.css, Mantine SCSS mixins
├── surface/ # Isolation infrastructure: documentSurface, createShadowSurfaceUi, createSatellite
├── core/ # Cross-context communication: messaging and RPC
└── constants/ # PROJECT_PREFIX, Z_INDEX tiers, shared design tokens
public/ # Static assets copied directly to the build output
vite-plugins/ # Custom build plugins (e.g., UTF-8 string escaping)
wxt.config.ts # Configuration for WXT, Vite, manifest definitions, and browser targets
src/entrypoints/ is the only directory automatically discovered by WXT. All other directories contain standard application code imported via the @/ alias, while WXT-specific APIs are accessed through the virtual #imports module. Each entry point serves as a minimal shim to mount a specific application from src/react-app/apps/.
Architectural Decision: This structure intentionally diverges from WXT's flat auto-import convention (which typically places
hooks/andutils/at the source root). By encapsulating application code withinreact-app/and mandating explicit imports, we enforce strict module boundaries. WXT's#importsfunctionality remains fully supported where necessary.
| Surface Type | Factory Method | Primary Use Case | Instance Limit |
|---|---|---|---|
| Document | documentSurface() + renderSurfaceApp() |
Popups, side panels, options pages, standalone tabs | 1 per page |
| Shadow | createShadowSurfaceUi() |
Comprehensive, isolated in-page UI injections | 1 per frame¹ |
| Satellite | createSatellite() |
Lightweight UI components (e.g., toolbars, badges) | Unlimited |
| Content | N/A (Refer to Patterns) | Direct mutation or styling of the host page | N/A |
¹ While createShadowSurfaceUi technically supports multiple instances, the satellite registry currently binds to a single host.
General Guidelines:
- UI Isolation: Render React DOM elements strictly within the isolated world. Reserve the main world for headless logic that explicitly requires the host page context.
- Surface vs. Content: If the host page must dictate the layout, utilize standard DOM elements (the Content track). If the extension requires full control over theme and layout, deploy an isolated Surface.
Whether rendering within a popup document or a shadow DOM, the environmental differences are abstracted into a unified interface (surface/defs.ts), which is consumed by the MantineRegistry:
interface SurfaceEnv {
kind: 'document' | 'shadow';
rootElement: HTMLElement; // The Mantine variable root and data-mantine-* attribute host
portalTarget: HTMLElement; // The injection target for floating layers (Modals, Popovers)
scale: number; // Rem compensation (16 / host's actual rem base). Always 1 for documents.
colorSchemeStrategy: 'extension' | 'force-light' | 'force-dark';
}Avoid direct manipulation of document.body. Always utilize the useSurface() hook to interact with the environment safely.
Satellites are performant, highly-targeted UI components injected into the host page. The host surface initializes the primary React providers and styles once. Subsequently, each satellite requires only an empty shadow container, shared CSSStyleSheet references, and a React portal.
Render content into a SatelliteOutlet using createSatellite(). The mounting of the outlet is automatically managed by renderSurfaceApp.
Satellites incorporate isolateEvents by default, ensuring that user keyboard interactions within the extension do not inadvertently trigger host page shortcuts. A MutationObserver monitors the DOM to maintain satellite alignment:
- Unmounting: Occurs instantaneously if the host element or anchor is removed from the DOM.
- Relocation: Adapts seamlessly if the anchor element shifts position on the page.
Styles are distributed via constructable stylesheets. If the browser does not support them, the system falls back to cloning <style> tags. (Note: During development, restrictive Content Security Policies may block injected <link> elements. Always verify styling against a production build, which securely inlines <style> tags.)
useVisual() is the primary hook for all theme-related operations. It replaces direct usage of Mantine's native hooks and provides:
- Color scheme management (
light,dark, orauto). - Text direction control (
ltr/rtlvia Mantine'sDirectionProvider). - Primary color generation. Providing a Mantine palette name (e.g.,
blue) or a hexadecimal code dynamically generates a complete 10-shade palette.
State synchronization across all surfaces is managed seamlessly via WXT storage. Modifying the theme within the popup will update the in-page UI in real-time.
For synchronizing custom reactive state, utilize the useExtStorage hook to wrap storage.defineItem across all instances. Ensure that object or array fallbacks utilize stable, module-level references.
The template provides two strictly-typed channels for cross-context communication:
- Messaging (
core/messaging): Registering an entry inProtocolMapautomatically provisions type-safesendMessageandonMessageutilities, eliminating string-based actions and manual listener management. - RPC (
core/rpc): Define a service class, register it within the background script, and invoke it from any surface via@webext-core/proxy-serviceas a local function. This is the recommended approach for heavy background operations, such as cross-origin requests, large downloads, or long-running tasks.
While the Shadow DOM provides robust encapsulation, CSS inheritance can still introduce edge cases. The following strategies address common issues:
| Issue | Symptom | Resolution |
|---|---|---|
| Inherited properties | Host page typography affects extension UI | Apply strict typography resets on .aie-surface-root |
rem sizing leaks |
Host's font-size scales the UI disproportionately |
Set theme.scale = 16 / real-rem to counteract the host's base |
@property collisions |
Tailwind CSS variables conflict with the host | Rename variables from --tw- to --aie-tw- (configured in postcss.config.cjs) |
Tailwind rem bugs |
Tailwind spacing breaks due to host rem scaling |
Apply calc(x * var(--mantine-scale, 1)) on .aie-surface-root |
Modal lockScroll |
Modals freeze the underlying host page | Set lockScroll: false on all Modals and Drawers |
lightningcss bugs |
High z-index values are unexpectedly collapsed |
Restrict Z_INDEX constants to 6 significant digits or fewer |
| External CSS overrides | Host CSS bypasses extension encapsulation | Enforce framework variables strictly within .aie-surface-root |
All root-level CSS variables are securely scoped within .aie-surface-root inside the shadow tree. This isolates them from external :root or :host selectors. Execute pnpm audit:css to verify the integrity of your design tokens.
Z-index Hierarchy: Defined as the Z_INDEX constant in src/constants and mirrored as --<prefix>-z-* CSS variables in global.css (a test in src/constants/index.test.ts keeps the two in sync).
Order: floatUi < mainUi < modal < popover < notification < max.
The surface keeps working on hosts with a strict Content-Security-Policy (e.g. script-src 'self'; style-src 'self'; style-src-attr 'none', no unsafe-inline). Content-script JS runs in the isolated world (governed by the extension's own CSP, not the page's), and all styling lands inside the shadow root — WXT's injected <style>, Mantine's theme variables, and satellites' adoptedStyleSheets — so the host's style-src/script-src never reach it. The one host-mutating exception (Mantine's color-scheme transition <style> injected into the page <head>) is disabled via keepTransitions: true. Verify with test-pages/csp-strict-host.html after pnpm build.
- Avoid
document.body: Rely onuseSurface()for all DOM interactions. - Preserve Host Integrity: Modals, Drawers, and Portals must not modify the host's
documentorbodystyles. - No Emotion in Content UI: Libraries that inject styles into
document.headare incompatible with shadow roots. Utilize the provided CSS modules and Mantine's native CSS variables. - Deploy Satellites for Secondary UI: Avoid initializing a complete surface for minor components. Route them through a satellite.
- Maintain a Simple Content Track: Do not apply Tailwind classes or React nodes for raw host-page mutations. Rely on vanilla DOM manipulation and explicitly prefixed CSS classes.
- Isolate Authentication: Keep sensitive UI workflows strictly out of the main world.
- Constrain Floating Components: Configure Popovers, Tooltips, and Menus to use
zIndex: Z_INDEX.popoverandfloatingStrategy: 'fixed'. - Enforce the Namespace: Change
PROJECT_PREFIXonly inprefix.cjs(the single source of truth);src/constants,postcss.config.cjs, and the CSS audit derive from it. Onlyglobal.cssis a manual mirror — andpnpm test/pnpm buildguard it against drift.
The following patterns outline recommended approaches for advanced implementations.
To highlight text or modify host content, implement a vanilla DOM module within the content script. The extension should manage the logic while allowing the host page to dictate the styling.
- Utilize
data-aie-*attributes as DOM markers. - For complex interactions, mount a satellite within the target node.
Interact with the main world only when access to host page JavaScript APIs is strictly required. Execute the necessary operations and immediately transmit the data back to the isolated world via @webext-core/messaging/page.
- Build Encoding:
vite-plugins/to-utf8.tsenforces UTF-8 escaping for non-ASCII characters in emitted chunks. This prevents load-time rejections by Chrome due to "non-UTF-8" errors in CJK or emoji-heavy bundles. This plugin is active by default and ignores pure-ASCII output. - Environment-Specific Builds: To implement development versus production logic, branch conditionally on
modewithin themanifestfunction inwxt.config.ts. - Custom Storage Hooks: Refer to
src/react-app/hooks/useExtStorage.tsfor implementation details.
Application-specific implementations (e.g., custom HTTP clients, OAuth via chrome.identity, DOM selection parsers) should be added to src/services or src/react-app/utils as required.
Popups, side panels, options pages, and tabs are pre-configured with documentSurface(). To introduce a new entry point, duplicate an existing one and integrate it accordingly.
- A satellite registry currently binds to a single host surface per frame.
- Rem scale is calculated once during initialization. If the host dynamically alters its root font size subsequently, the extension UI will not automatically rescale.
- The
MutationObservermonitors the entire DOM tree. In highly dynamic Single Page Applications (SPAs), implement explicit teardown logic to optimize CPU utilization. - Event isolation is only guaranteed during the bubble phase.
The foundational dependencies this template is built on:
| Package | Role | Docs |
|---|---|---|
| WXT | Extension framework — entrypoints, manifest generation, dev/build/zip | wxt.dev |
@webext-core (messaging, proxy-service) |
Typed cross-context messaging and background RPC | webext-core.aklinker1.io |
| Mantine | UI components and theming on native CSS variables | mantine.dev |
React (via @wxt-dev/module-react) |
UI runtime and the WXT React module | react.dev |
| Tailwind CSS | Namespaced utility CSS (v4, lightningcss) | tailwindcss.com |
| TypeScript | Language and type checking | typescriptlang.org |
| Vite (rolldown) | The bundler WXT builds on | vite.dev |
| Vitest | Unit tests (happy-dom + fake-browser) | vitest.dev |
Exact versions are pinned in package.json.