Building Modern Cross Browser Web Extensions: Content Scripts and UI (Part 3)

  January 24, 2025

 #webextensions#typescript#react#wxt

In the previous post, we installed WXT, TailwindCSS and Shadcn and explored the project structure. In this post, we will explore how to work with content scripts and build isolated UIs for our extension as well as discuss some issues with UI and how to resolve them. We will build a Command Palette for quick browser actions, such as creating new tabs/windows, muting/unmuting tabs, and more over the next few posts.

Content Scripts

Content scripts are JavaScript files that run in the context of web pages. They can read and modify the DOM of web pages the browser visits but have limited access to browser APIs. We need to use message passing to communicate and get information through background script, which has access to more browser APIs.

Injecting Content Scripts

In the previous post, we saw that our project has a content script at src/entrypoints/content.ts. The WXT documentation provides information about how we can organize our content scripts in the entrypoints section. We will rename our content script to main.content.tsx. The documentation has 3 methods for injecting UIs into the pages with well explained differences. We’ll go with the Shadow Root method as it is pretty straightforward and provides isolation for our CSS styling.

First, let’s add the Shadcn Command component to our project:

pnpm dlx shadcn@latest add command

In the src/components/ create a new file CommandPalette.tsx and add the example code from the Shadcn website for using Command with Dialog. Rename the default export to CommandPalette from CommandDialogDemo.

In our main.content.tsx, we will remove the existing code and add the following:

src/entrypoints/main.content.tsx
import ReactDOM from "react-dom/client";
import { CommandPalette } from "@/components/CommandPalette";
import "@/index.css";

export default defineContentScript({
  matches: ["<all_urls>"],
  cssInjectionMode: "ui",

  async main(ctx) {
    const ui = await createShadowRootUi(ctx, {
      name: "command-palette",
      position: "inline",
      anchor: "body",
      onMount: (container) => {
        const app = document.createElement("div");
        app.id = "command-palette-root";
        container.append(app);

        const root = ReactDOM.createRoot(app);
        root.render(<CommandPalette />);
        return root;
      },
      onRemove: (root) => {
        root?.unmount();
      },
    });

    ui.mount();
  },
});

Few things to note here:

  1. ctx, the parameter to the main function, provides information about the context. When an extension is disabled or uninstalled, the content script is not removed. In such cases, the “context” of our content script becomes invalid. We can use ctx to monitor if the context is still valid, for example to remove event listeners. The UI is removed automatically when the context becomes invalid, so we don’t need to use it explicitly here.
  2. We are using createShadowRootUi to create a shadow root for our UI.(This code is same from the docs). In the DOM, our shadow root will be in a command-palette element (the name we passed to createShadowRootUi) like:
<command-palette
  data-wxt-shadow-root=""
  data-aria-hidden="true"
  aria-hidden="true"
>
  #shadow-root (open)
  <html>
    <head></head>
    <body>
      <div id="command-palette-root"></div>
    </body>
  </html>
</command-palette>
  1. The main function in content scripts can be async, but not in background scripts. We are using async here.

  2. Since we are using <all_urls> in the matches array, our content script will run on all pages. We can restrict it to specific pages by using a URL pattern.

  3. Importing @/index.css in the content script will inject the CSS into the shadow DOM, allowing our Shadcn components to use the styles.

We have used distinct names for the shadow root and the root element of our UI, so that they are easily identifiable in the DOM. You can inspect the DOM to see the shadow root and the root element structured as shown above.

There are some issues with the dialog that we need to resolve though.

Resolving Issues With The UI

If you go to any webpage and inspect the elements, you will see the above element in the DOM. However, if we try to trigger the dialog, it will look broken like this:

The broken command dialog at the bottom of the page

The issue here is that by default on keypress, the dialog div is added to the main DOM and not the shadow DOM. Shadcn is built on RadixUI, hence we can fix this by using RadixUI portals.

Using RadixUI Portals To Move The Dialog To Shadow DOM

  1. In our main.content.tsx, create a portal context and a content root component:
src/entrypoints/main.content.tsx
import React from "react";

export const PortalContext = React.createContext<HTMLElement | null>(null);

const ContentRoot = () => {
  const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(
    null,
  );

  return (
    <React.StrictMode>
      <PortalContext.Provider value={portalContainer}>
        <div ref={setPortalContainer} id="command-portal-container">
          <CommandPalette />
        </div>
      </PortalContext.Provider>
    </React.StrictMode>
  );
};
  1. In the same file, update the main function to use the ContentRoot instead of CommandPalette:
src/entrypoints/main.content.tsx
root.render(<ContentRoot />);
  1. Ensure that you have @/index.css imported in your src/entrypoints/main.content.tsx file.

  2. In the src/components/ui/dialog.tsx import the PortalContext and useContext, and update the DialogContent component to use our portal:

src/components/ui/dialog.tsx

import { useContext } from "react";
import { PortalContext } from "@/entrypoints/main.content.tsx";

//... rest of the code as is

const DialogContent = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
  <DialogPortal container={useContext(PortalContext)}>
    <DialogOverlay />

After this, the dialog should be rendered properly:

What we simply did here, is creating a ‘div’ and using it as a portal where our dialog will be rendered, thus bringing it inside the Shadow DOM.

Isolating Events

By default, events in the Shadow DOM are not isolated from the main DOM. This means the events such as key presses and scroll can still affect the main page. To see this in action, try opening YouTube, playing a video, and then opening our Command Palette. If you press the m key, the video will mute, showing that the event has bubbled up to the main DOM. Additionally, you’ll notice that scrolling doesn’t work as expected within the Command Palette. We can easily isolate events by updating our createShadowRootUi function:

src/entrypoints/main.content.tsx
  async main(ctx) {
    const ui = await createShadowRootUi(ctx, {
      name: "command-palette",
      position: "inline",
      anchor: "body",
      isolateEvents: ["keydown", "keyup", "keypress", "wheel"],  // Add other events as needed
      onMount: (container) => {

    //... rest of the code as is

Once you add the isolateEvents array, the events will be isolated to the shadow DOM, and both keypresses and scrolling will work as expected.

Configuring PostCSS To Convert rem To px

There is one other issue that is not be immediately visible, but you might encounter on some pages. In Shadcn and TailwindCSS, rem units are used widely. rem units are relative to root element’s font size, and in Shadow DOM, the rem unit is actually relative to the font size of the main document’s root element (i.e., the html element), not the shadow root, and this might affect our UI.

The fix for this is rather simple: using other units like px, such as with this package. We can do it as follows:

  1. Install the package:
# https://github.com/TheDutchCoder/postcss-rem-to-px
npm install --save-dev postcss @thedutchcoder/postcss-rem-to-px

#or
pnpm add -D postcss @thedutchcoder/postcss-rem-to-px
  1. Update our postcss.config.js to use the plugin:
postcss.config.js
export default {
  plugins: {
    autoprefixer: {},
    "@thedutchcoder/postcss-rem-to-px": {},
    tailwindcss: {},
  },
};

We can now safely use rem units in our CSS, and they will be converted to px units in the Shadow DOM automatically during build.

Note that we only have the main.content.tsx as a content script, and any UI component can be directly used by importing without marking them as content script separately (just as we don’t have CommandPalette marked as content script explicitly anywhere).

Other Styling Fixes

You might also see some minor styling issues with the dialog such as z-index (again, noticable on YouTube), which can easily fixed by adding custom CSS either in src/index.css itself or using a separate file:

src/index.css
div[role="dialog"] {
  z-index: 999999;
}

The ShadowDOM, event isolation and rem to px conversion are one time fixes, while you might need styling fixes for UI based on the components you use. We now have a properly isolated UI for our extension, and we can start adding more features to it.

In the next post, we will explore background scripts, the communication process in extensions, and setting up a robust type-safe messaging system for our extension.

Share your feedback: [email protected]

Last updated on