Develop Chrome Extensions using React, Typescript, and Shadow DOM

Samuel Kollát
Outreach Prague
Published in
5 min readOct 3, 2023

If you are a front-end developer, you’re probably no stranger to building web applications with React. But have you ever considered extending your skills to create Chrome extensions? Chrome extensions can add new functionality and enhance the browsing experience for millions of users. This blog post will explore how to develop Chrome extensions using React, Typescript, and Shadow DOM.

Why React and Shadow DOM?

Shadow DOM is a technology that allows you to encapsulate the style and behavior of your web components, preventing them from clashing with the styles of the page they’re injected into. React, on the other hand, is a popular JavaScript library for building user interfaces. Combining these technologies in a Chrome extension can provide a clean and maintainable way to create custom UI components that won’t interfere with the host page.

Step 1: Create a new project

In the first step, let’s create a new project using Vite. The project will be based on React, Typescript, yarn, and SWC (Speedy Web Compiler):

yarn create vite my-chrome-extension --template react-swc-ts

This will create a new repository called my-chome-extension with a standard React application based on the above mentioned technologies. To upgrade this project to support Chrome extension development, let's add a new package:

yarn add --dev @crxjs/vite-plugin

The CRXJS Vite plugin seamlessly integrates with the Vite ecosystem and allows building and developing Chrome extensions with the support of HMR (Hot Module Replacement), thus improving the developer experience. React SPAs built files can be served from memory and do not need to exist in any disk folder. On the other hand, Chrome extension’s built artifacts must be stored in some folder from which the browser can load and install them. This is taken care of by the CRXJS plugin.

You can follow along using the example repository created for this blog post. See the Step 1 branch.

Step 2: Manifest file

The Manifest file defines configuration and essential definitions related to extensions. Based on this file, the browser installs and enables features for them.

Using Manifest V3

Google has introduced Manifest V3 as a more secure and efficient way to build extensions. It enforces stricter security policies and encourages developers to use modern JavaScript features. One of the key changes in Manifest V3 is the move from background pages to background workers.

In our case, let’s keep the Manifest definition simple and enable only features that will help us showcase the usage of React and Shadow DOM.

{
"manifest_version": 3,
"name": "My Chrome Extension with React and ShadowDOM",
"version": "1.0.0",
"action": { "default_popup": "index.html" },
"content_scripts": [
{
"matches": ["https://www.google.com/*"],
"js": ["src/content/google.tsx"]
}
]
}

Here, we’ve defined two primary resources:

  • Popup — a UI shown when a user clicks on the extension’s icon in the browser's toolbar.
  • Content script — a script that will be injected into a domain matching https://*.google.com/* format any time a user visits such domain.

Popup

We used the default index.html created by the Vite bootstrap process for the Popup. As the Popup is a separate HTML tree, it can directly contain a standard React application with all the bells and whistles we are used to from SPA development.

At this time, we can also register the manifest with CRXJS in the vite.config.ts:

export default defineConfig({
plugins: [react(), crx({ manifest })],
});

See the Step 2 branch or the changelog.

Step 3: Shadow DOM component

Now, we are getting to the Shadow DOM part. Enabling the rendering of React components into the host page requires us to:

  1. Create a new DOM element representing a host element for the shadow DOM. Let’s call it my-shadow-host
  2. Attach a new shadow DOM to the host element
  3. Insert the host element into the page’s DOM structure represented by the component’s property parentElement.
  4. Create a React portal into the shadow DOM, allowing us to render any React component.
import React from "react";
import ReactDOM from "react-dom";

export function ShadowDom({
parentElement,
position = "beforebegin",
children,
}: {
parentElement: Element;
position?: InsertPosition;
children: React.ReactNode;
}) {
const [shadowHost] = React.useState(() =>
document.createElement("my-shadow-host")
);

const [shadowRoot] = React.useState(() =>
shadowHost.attachShadow({ mode: "closed" })
);

React.useLayoutEffect(() => {
if (parentElement) {
parentElement.insertAdjacentElement(position, shadowHost);
}

return () => {
shadowHost.remove();
};
}, [parentElement, shadowHost, position]);

return ReactDOM.createPortal(children, shadowRoot);
}

You can now use the ShadowDom component to render any React component into a target page. You only need to define an element in the target page you want to enhance and your React component.

See the Step 3 branch or the changelog.

Step 4: Content script

At first, we want to identify a place within our target page we want to enhance with our extension. For the demonstration purpose, I chose the Google page. By inspecting the page with the Chrome devtools, we can identify the Google logo element by its CSS selector. In our case, it is a class named k1zIA.

Note: In the production scenario, you’d want to use MutationObserver for a more robust solution.

import React from "react";
import { ShadowDom } from "./ShadowDom";

export function Google(): React.ReactElement | null {
const [parentElement] = React.useState(() =>
document.querySelector(".k1zIA")
);

return parentElement ? (
<ShadowDom parentElement={parentElement}>Hello 👋, </ShadowDom>
) : null;
}

How do we render this whole React tree into the page? Let’s write a function that will help us with it:

import { createRoot } from "react-dom/client";

export function render(content: React.ReactElement) {
const container = document.createDocumentFragment();
const root = createRoot(container);
root.render(content);
}

The createDocumentFragment method on the document enabled us to build an offscreen DOM tree to render React content.

Finally, we can render our Google component into a React-managed context with a closed Shadow DOM:

import { Google } from "../components/Google";
import { render } from "./render";

render(<Google />);

See the Step 4 branch or the changelog.

Step 4: Run and install

Run the build with the development server and HMR:

yarn dev

And install your new extension from the newly created dist folder to Chrome. Here is the official documentation with easy steps on how to do it.

And here is the result 🎉

Conclusion

Building Chrome extensions with React, Typescript, and Shadow DOM can provide a powerful way to enhance the browsing experience for users. This blog post is just the beginning of what you can achieve with Chrome extensions.

Feel free to explore more advanced features like background workers, storage, and communication between components. Consult the official Chrome Extension documentation for detailed information and best practices.

Happy coding, and may your Chrome extensions bring joy to users everywhere!

--

--