Skip to content

Content Script UI

There are three ways to mount a UI inside a content script:

Each has their own set of advantages and disadvantages.

MethodIsolated StylesIsolated EventsHMRUse page's context
Integrated
Shadow Root✅ (off by default)
IFrame

Integrated

Integrated content script UIs are injected alongside the content of a page. This means that they are affected by CSS on that page.

ts
// entrypoints/example-ui.content.ts
export default defineContentScript({
  matches: ['<all_urls>'],

  main(ctx) {
    const ui = createIntegratedUi(ctx, {
      position: 'inline',
      onMount: (container) => {
        // Append children to the container
        const app = document.createElement('p');
        app.textContent = '...';
        container.append(app);
      },
    });

    // Call mount to add the UI to the DOM
    ui.mount();
  },
});
ts
// entrypoints/example-ui.content/index.ts
import { createApp } from 'vue';
import App from './App.vue';

export default defineContentScript({
  matches: ['<all_urls>'],

  main(ctx) {
    const ui = createIntegratedUi(ctx, {
      position: 'inline',
      onMount: (container) => {
        // Create the app and mount it to the UI container
        const app = createApp(App);
        app.mount(container);
        return app;
      },
      onRemove: (app) => {
        // Unmount the app when the UI is removed
        app.unmount();
      },
    });

    // Call mount to add the UI to the DOM
    ui.mount();
  },
});
tsx
// entrypoints/example-ui.content/index.tsx
import ReactDOM from 'react-dom/client';
import App from './App.tsx';

export default defineContentScript({
  matches: ['<all_urls>'],

  main(ctx) {
    const ui = createIntegratedUi(ctx, {
      position: 'inline',
      onMount: (container) => {
        // Create a root on the UI container and render a component
        const root = ReactDOM.createRoot(container);
        root.render(<App />);
        return root;
      },
      onRemove: (root) => {
        // Unmount the root when the UI is removed
        root.unmount();
      },
    });

    // Call mount to add the UI to the DOM
    ui.mount();
  },
});
ts
// entrypoints/example-ui.content/index.ts
import App from './App.svelte';

export default defineContentScript({
  matches: ['<all_urls>'],

  main(ctx) {
    const ui = createIntegratedUi(ctx, {
      position: 'inline',
      onMount: (container) => {
        // Create the Svelte app inside the UI container
        const app = new App({
          target: container,
        });
        return app;
      },
      onRemove: (app) => {
        // Destroy the app when the UI is removed
        app.$destroy();
      },
    });

    // Call mount to add the UI to the DOM
    ui.mount();
  },
});
tsx
// entrypoints/example-ui.content/index.ts
import { render } from 'solid-js/web';

export default defineContentScript({
  matches: ['<all_urls>'],

  main(ctx) {
    const ui = createIntegratedUi(ctx, {
      position: 'inline',
      onMount: (container) => {
        // Render your app to the UI container
        const unmount = render(() => <div>...</div>, container);
      },
      onRemove: (unmount) => {
        // Unmount the app when the UI is removed
        unmount();
      },
    });

    // Call mount to add the UI to the DOM
    ui.mount();
  },
});

See the API Reference for the complete list of options.

You can control how CSS is injected for an integrated content script UI with the cssInjectionMode property. Usually, you'll want to leave it as "manifest", the default, so the UI inherits its style from the website's CSS.

Shadow Root

Often in web extensions, you don't want your content script's CSS affecting the page, or vise-versa. The ShadowRoot API is ideal for this.

WXT's createShadowRootUi abstracts all the ShadowRoot setup away, making it easy to create UIs with isolated CSS. It also supports an optional isolateEvents parameter to further isolate user interactions.

To use createShadowRootUi, follow these steps:

  1. Import your CSS file at the top of your content script
  2. Set cssInjectionMode: "ui" inside defineContentScript
  3. Define your UI with createShadowRootUi()
  4. Mount the UI so it is visible to users
ts
// 1. Import the style
import './style.css';

export default defineContentScript({
  matches: ['<all_urls>'],
  // 2. Set cssInjectionMode
  cssInjectionMode: 'ui',

  async main(ctx) {
    // 3. Define your UI
    const ui = await createShadowRootUi(ctx, {
      name: 'example-ui',
      position: 'inline',
      onMount(container) {
        // Define how your UI will be mounted inside the container
        const app = document.createElement('p');
        app.textContent = 'Hello world!';
        container.append(app);
      },
    });

    // 4. Mount the UI
    ui.mount();
  },
});
ts
// 1. Import the style
import './style.css';
import { createApp } from 'vue';
import App from './App.vue';

export default defineContentScript({
  matches: ['<all_urls>'],
  // 2. Set cssInjectionMode
  cssInjectionMode: 'ui',

  async main(ctx) {
    // 3. Define your UI
    const ui = await createShadowRootUi(ctx, {
      name: 'example-ui',
      position: 'inline',
      onMount: (container) => {
        // Define how your UI will be mounted inside the container
        const app = createApp(App);
        app.mount(container);
        return app;
      },
      onRemove: (app) => {
        // Unmount the app when the UI is removed
        app?.unmount();
      },
    });

    // 4. Mount the UI
    ui.mount();
  },
});
tsx
// 1. Import the style
import './style.css';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';

export default defineContentScript({
  matches: ['<all_urls>'],
  // 2. Set cssInjectionMode
  cssInjectionMode: 'ui',

  async main(ctx) {
    // 3. Define your UI
    const ui = await createShadowRootUi(ctx, {
      name: 'example-ui',
      position: 'inline',
      onMount: (container) => {
        // Container is a body, and React warns when creating a root on the body, so create a wrapper div
        const app = document.createElement('div');
        container.append(app);

        // Create a root on the UI container and render a component
        const root = ReactDOM.createRoot(app);
        root.render(<App />);
        return root;
      },
      onRemove: (root) => {
        // Unmount the root when the UI is removed
        root?.unmount();
      },
    });

    // 4. Mount the UI
    ui.mount();
  },
});
ts
// 1. Import the style
import './style.css';
import App from './App.svelte';

export default defineContentScript({
  matches: ['<all_urls>'],
  // 2. Set cssInjectionMode
  cssInjectionMode: 'ui',

  async main(ctx) {
    // 3. Define your UI
    const ui = await createShadowRootUi(ctx, {
      name: 'example-ui',
      position: 'inline',
      onMount: (container) => {
        // Create the Svelte app inside the UI container
        const app = new App({
          target: container,
        });
        return app;
      },
      onRemove: (app) => {
        // Destroy the app when the UI is removed
        app?.$destroy();
      },
    });

    // 4. Mount the UI
    ui.mount();
  },
});
tsx
// 1. Import the style
import './style.css';
import { render } from 'solid-js/web';

export default defineContentScript({
  matches: ['<all_urls>'],
  // 2. Set cssInjectionMode
  cssInjectionMode: 'ui',

  async main(ctx) {
    // 3. Define your UI
    const ui = await createShadowRootUi(ctx, {
      name: 'example-ui',
      position: 'inline',
      onMount: (container) => {
        // Render your app to the UI container
        const unmount = render(() => <div>...</div>, container);
      },
      onRemove: (unmount) => {
        // Unmount the app when the UI is removed
        unmount?.();
      },
    });

    // 4. Mount the UI
    ui.mount();
  },
});

See the API Reference for the complete list of options.

TailwindCSS

createShadowRootUi supports TailwindCSS out of the box! When importing the styles, just import the main CSS file containing the @tailwind directives, and everything will just work 👍.

IFrame

If you don't need to run your UI in the same frame as the content script, you can use an IFrame to host your UI instead. Since an IFrame just hosts an HTML page, HMR is supported.

WXT provides a helper function, createIframeUi, which simplifies setting up the IFrame.

  1. Create an HTML page that will be loaded into your IFrame

    html
    <!-- entrypoints/example-iframe.html -->
    <!doctype html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Your Content Script IFrame</title>
      </head>
      <body>
        <!-- ... -->
      </body>
    </html>
  2. Add the page to the manifest's web_accessible_resources

    ts
    // wxt.config.ts
    export default defineConfig({
      manifest: {
        web_accessible_resources: [
          {
            resources: ['example-iframe.html'],
            matches: [...],
          },
        ],
      },
    });
  3. Create and mount the IFrame

    ts
    export default defineContentScript({
      matches: ['<all_urls>'],
    
      main(ctx) {
        // Define the UI
        const ui = createIframeUi(ctx, {
          page: '/example-iframe.html',
          position: 'inline',
          onMount: (wrapper, iframe) => {
            // Add styles to the iframe like width
            iframe.width = '123';
          },
        });
    
        // Show UI to user
        ui.mount();
      },
    });

See the API Reference for the complete list of options.