How to Use React Shadow DOM with CSS Modules + style-loader

Updated

4 min read

How to Use React Shadow DOM with CSS Modules + style-loader

Using CSS modules with the shadow DOM may seem counterintuitive or redundant because they address the same problem of encapsulating styles.

After all, CSS modules are designed to facilitate component-based styling: each style is scoped to a specific component to prevent style conflicts.

Note

If you find yourself trying to use Shadow DOM with CSS modules, first ask yourself if you really need both.

Why You Might Use CSS Modules + Shadow DOM

There are some valid reasons to use CSS modules + Shadow DOM, such as:

  • Existing code base: Your existing codebase or framework uses CSS modules and you want gradual adoption of web components.
  • Reusable design systems: you want to use existing CSS modules for web components using Shadow DOM
  • Monorepo: you may have CSS-module-styled components which you use internally in a web app and also distribute as a standalone React bundle.
  • Embeddable widgets: you may distribute a React bundle as a widget for other users to use. While CSS modules prevent style conflicts by generating unique classnames, there is still the possibility that existing styles on the consumers' sites override, e.g. #section p { // these styles would have higher specificity }. In this case, using Shadow DOM would provide additional style encapsulation to cover these edge cases.

Whatever your reason is for using CSS modules and Shadow DOM, here's how to get them to play nice together.

Using CSS Modules with Shadow DOM

Here's my quick (albeit somewhat hacky) way of easily getting CSS modules to work with Shadow DOM in React.

First, we'll attach a shadow root and pass it to our main <App /> React entry point component.

index.tsx
javascriptconst root = document.getElementById("root");

// Create a shadow root
const shadowRoot = root!.attachShadow({ mode: "open" });

// Create a container for your React app inside the shadow DOM
const reactContainer = document.createElement("div");
shadowRoot.appendChild(reactContainer);

const root = createRoot(reactContainer);
root.render(
    <App
        shadowRoot={shadowRoot} // Pass shadowRoot to App
    />
);

Then in our App.tsx, which is a React functional component, we'll use a useEffect to copy the style-loader-injected style tags into our Shadow DOM.

App.tsx
javascript
const App: React.FC<{ shadowRoot: ShadowRoot }> = ({ shadowRoot }) => {

    useEffect(() => {
        const styles = document.querySelectorAll("style");
        styles.forEach((style) => {
            const clonedStyle = style.cloneNode(
                true
            ) as HTMLStyleElement;
            shadowRoot.appendChild(clonedStyle);
        });
    }, [shadowRoot]);

    return (
        <div>
            {/* Rest of your React app */}
        </div>
    );
}

Here we are copying all the <style> tags into our Shadow DOM.

Since we're using style-loader, our CSS modules are injected into the DOM as <style> tags.

However, you can also target only style tags created by style-loader, like so:

App.tsx
javascriptconst styles = document.querySelectorAll("style[data-my-app-styles]")

And then in our Webpack config, we'll make style-loader add the data-my-app-styles attribute to each <style> tag it injects.

Configuring Webpack

Here's our basic Webpack setup for getting this to work.

webpack.config.js
javascriptconst path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const Dotenv = require('dotenv-webpack');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  entry: './src/index.tsx',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js',
  },
  module: {
    rules: [
        {
            test: /\.(ts|tsx)$/,
            exclude: /node_modules/,
            include: path.resolve(__dirname, 'src'),
            use: 'ts-loader',
        },
        {
          test: /\.module\.css$/,
          use: [
            {
              loader: 'style-loader',
              options: {
                attributes: {
                  'data-my-app-styles': true,
                },
              },
            },
          ],
        },
        {
            test: /\.css$/,
            exclude: /\.module\.css$/,
            use: [
              {
                loader: 'style-loader',
                options: {
                  attributes: {
                    'data-my-app-styles': true,
                  },
                },
              },
            ],
          },
    ],
  },
  resolve: {
    extensions: ['.ts', '.tsx'],
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './public/index.html',
    }),
    new Dotenv(),
  ],
  devServer: {
    static: {
      directory: path.join(__dirname, 'public'),
    },
    port: 3000,
  },
};

By adding a data attribute to our <style> tags, we can update our useEffect to only inject the style tags for our React app:

javascriptuseEffect(() => {
    const styles = document.querySelectorAll("style[data-my-app-styles]");
    styles.forEach((style) => {
        const clonedStyle = style.cloneNode(
            true
        ) as HTMLStyleElement;
        shadowRoot.appendChild(clonedStyle);
    });
}, [shadowRoot]);

Summary

To summarize, we copy <style> tags into our Shadow DOM and add a data attribute via our Webpack config to only copy the style-loader style tags we want.

While CSS modules and Shadow DOM may be unconventional and rarely used together (for good reason), hopefully this provides a simple, albeit somewhat hacky, way of ensuring your Shadow DOM can utilize existing CSS modules.

Ryan Chiang

Meet the Author

Ryan Chiang

Hello, I'm Ryan. I build things and write about them. This is my blog of my learnings, tutorials, and whatever else I feel like writing about.
See what I'm building →.

Thanks for reading! If you want a heads up when I write a new blog post, you can subscribe below:

2024

2023

© 2023 Ryan Chiang|ryanschiang.com