Building Modern Cross Browser Web Extensions: Content Scripts and UI (Part 3)
#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:
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:
ctx
, the parameter to themain
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 usectx
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.- 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 acommand-palette
element (the name we passed tocreateShadowRootUi
) 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>
The
main
function in content scripts can be async, but not in background scripts. We are using async here.Since we are using
<all_urls>
in thematches
array, our content script will run on all pages. We can restrict it to specific pages by using a URL pattern.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 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
- In our
main.content.tsx
, create a portal context and a content root component:
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>
);
};
- In the same file, update the
main
function to use theContentRoot
instead ofCommandPalette
:
root.render(<ContentRoot />);
Ensure that you have
@/index.css
imported in yoursrc/entrypoints/main.content.tsx
file.In the
src/components/ui/dialog.tsx
import thePortalContext
anduseContext
, and update theDialogContent
component to use our portal:
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:
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:
- 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
- Update our
postcss.config.js
to use the plugin:
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:
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]