Using Shadow DOM to isolate injected browser extension components

Written on

I recently started experimenting with building a browser extension. I’m using React and TailwindCSS —bundling it all with Vite— creating some UI elements like buttons and injecting them into certain web pages. Setting this up was super easy, I used a repository template to get a PoC as quickly as possible, and a few minutes later I had something working!

One issue with injecting components into web pages though is that your components are injected into the regular page DOM. This means that your components can be affected by scripts or CSS on the page, and conversely your CSS can affect existing elements on the page should anything collide.

This is a bit of a problem when using TailwindCSS. On one hand, utility classes have the same meaning across websites. For example, w-full is always going to be width: 100%;, because what else would it be? On the other hand, websites are always able to change what CSS these utility classes map to. rounded might mean different amounts of rounding on different websites, and bg-color-primary is going to map to different colors based on the color palette of the website. And nobody can stop you if you want to change w-full to set the width to 10 pixels.

Avoiding these collisions in the past would have involved prefixing all your IDs and classes so that they are distinct from any ones you might encounter on the page. Even then, Javascript code of the original page can traverse the components you inject on the page and inspect or modify them! Nowadays though, Shadow DOM allows us to easily isolate components on the page. Using Shadow DOM, the CSS of the injected components is isolated from the rest of the page. That isolation works in both directions, the CSS from the original page won’t affect the elements in the Shadow DOM, and the CSS injected into the Shadow DOM won’t affect the elements outside of it. Plus, Shadow DOM has an option to isolate the injected components from Javascript of the original page so they can’t traverse into the Shadow DOM (the original page can still delete or move the root of the Shadow DOM, but can’t look inside of it).

And the code for this is very simple! Shadow DOM might sound a bit scary, but it’s actually very easy. Let’s start with some basic code to inject a React component into a page:

import styles from "./style.css";
import { createRoot } from "react-dom/client";

const injectionTarget = document.querySelector<HTMLDivElement>(
  "#place-to-inject-my-component",
);
if (!injectionTarget) {
  throw new Error("Can't find injection target");
}

const rootContainer = document.createElement("div");
injectionTarget.appendChild(rootContainer);

const root = createRoot(rootContainer);
root.render(<YourElement />);

To use Shadow DOM, we need to create the Shadow DOM and attach it to the page, then inject our React root inside of the Shadow DOM. This is as easy as calling attachShadow! Let’s modify the example to use the Shadow DOM:

import styles from "./style.css";
import { createRoot } from "react-dom/client";

const injectionTarget = document.querySelector<HTMLDivElement>(
  "#place-to-inject-my-component",
);
if (!injectionTarget) {
  throw new Error("Can't find injection target");
}

const shadowDomRoot = document.createElement("div");
injectionTarget.appendChild(shadowDomRoot);
const shadow = shadowDomRoot.attachShadow({
  // closed means the contents of the shadow can't be accessed from the page
  mode: "closed"
});

const rootContainer = document.createElement("div");
shadow.appendChild(rootContainer);

const root = createRoot(rootContainer);
root.render(<YourElement />);

Nice! We just had to add about 3 lines of code, and now our components lives inside it’s own bubble.

Now, if you are following along with this example and running the code, you might have noticed that your injected component suddenly has no styles applied to it now. Well, that’s the CSS isolation the Shadow DOM gives you. How do we get around this? That’s not too much work either, we just need to inject the CSS into the page too.

First, you can use the manifest file to inject CSS into the page declaratively. This is in fact what the repository template I linked above does. But that’s not what we want here, because we don’t want the CSS to be just injected into the page. We want it injected specifically into the Shadow DOM we just created. So, we’ll have to disable this declarative CSS injection.

  "content_scripts": [
    {
      "matches": ["http://*/*", "https://*/*", "<all_urls>"],
      "js": ["src/pages/content/index.tsx"],
      // Make sure this is empty
      "css": []
    }
  ],

Now that we got rid of that, we’ll want to import the CSS into our script and inject that along too. The trick here is adding ?inline to the end of the CSS import so that rather than injecting the CSS into the page, Vite understands that we want the contents of the CSS.

// Note the `?inline`
import styles from './style.css?inline';
import { createRoot } from 'react-dom/client';

// ... same as example above

// Create the style element, put the CSS into it,
// and inject it into the Shadow DOM.
const style = document.createElement('style');
style.innerHTML = styles;
shadow.appendChild(style);

// ... same as example above

Tada! Your component should now light up with all the fancy CSS —or utility classes— you added to it. While the Shadow DOM example is slightly more complex than the most basic option I demonstrated first, the advantages you get in CSS and JS isolation are worth it considering it only takes a few lines of text.