Oct 24, 2021 · 2 min

Using CSS files in web components

#react
#javascript
#css
Vladimir
Vladimir Zotov@vzotov
Using CSS files in web components

When building a microfrontend in React using tools like create-react-app, Webpack, or Vite you might expect plain CSS files and CSS modules to just work, but they don’t 😱.

Here is an example of a CSS module in a React component:

import styles from './ConfirmButton.module.css';
...
const ConfirmButton = () => <button className={styles.confirmButton}>Ok</button>;
// It renders something like: <button class="confirmButton_fwi4g3">Ok</button*

And here is how that CSS was injected into DOM by the bundler:

<html>
  <head>
    <style type="text/css">
      .confirmButton_fwi4g3 {
        ...
    </style>
    <my-microfrontend>
      #shadow-root
      <button className="confirmButton_fwi4g3">Ok</button>
    </my-microfrontend>
  </head>
</html>

This is development mode, hence <style> tags. In production mode those will be <link> tags. In both cases the CSS doesn't get applied to our microfrontend.

The problem is that Shadow DOM prevents CSS from leaking in and out of it, which is the whole point of using it. Styles need to somehow be placed within a web component instance. And when every new web component gets created, the styles must be copied into them as well!

Solution for production mode

The solution is to extract URLs to the CSS files from a special JSON file and insert them into our Web Components. That special JSON file is called an asset manifest. You can build one using an option in a Webpack/Vite config. Create-React-App generates one by default.

  • For an app built with create-react-app that file is asset-manifest.json.
  • For Vite it is manifest.json (you need to enable manifest option).
  • For Webpack you can generate an asset manifest file with asset manifest plugin.

These files might have slightly different structure, here is an example for Vite:

project-dir/dist/manifest.json

{
  "index.html": {
    "file": "assets/index.e901b340.js",
    "src": "index.html",
    "isEntry": true,
    "imports": ["_vendor.d74ea55b.js"],
    "css": ["assets/index.08fbfe68.css"]
  },
  "_vendor.d74ea55b.js": {
    "file": "assets/vendor.d74ea55b.js",
    "css": ["assets/vendor.a9bef22f.css"]
  }
}

You will need to filter URLs that end with .css extension. They all are relative to the base URL of your app. If your app is a CommonJS-based bundle, you can discover the base URL from document.currentScript.src and if you built a ECMAScript module-based bundle use import.meta.url).

Here is an example for Vite:

export function ProdStyles() {
  const [cssUrls, setCssUrls] = useState(null);
  useEffect(() => {
    const assetManifestUrl = new URL('manifest.json', import.meta.url).toString();
    getAssetsFromWebpack4AssetManifest(assetManifestUrl).then((assets) => {
      setCssUrls(assets.cssUrls);
    });
  }, []);

  return (
    <>
       {cssUrls?.map((url) => (
          <link
            type="text/css"
            rel="stylesheet"
            href={url}
            onError={incrementLinkCount}
            onLoad={incrementLinkCount}
          />
        ))
      )}
    </>
  );
}

const Root = () => {
  // <App> component would ideally only render
  // after all of the CSS assets were loaded,
  // but I omitted the check for simplicity.
  return (
    <>
      <ProdStyles />
      <App />
    </>
  );
}

async function getAssetsFromWebpack4AssetManifest(baseUrl) {
  const getAbsoluteUrl = path => new URL(path, baseUrl).toString();
  const response = await fetch(getAbsoluteUrl('/asset-manifest.json'));
  if (!response.ok) {
    throw new Error('Failed to get manifest');
  }
  const manifest = await response.json();
  const relativeCssUrls = manifest.entrypoints.filter(x => x.endsWith('.css'));
  return {
    cssUrls: relativeCssUrls.map(getAbsoluteUrl),
  };
}

Solution for development mode

We can make styles apply in development mode in two steps.

First, a PostCSS plugin that prepends a comment with some unique web component ID to every CSS file. The ID could literally be the name of your web component, e.g. my-account-page or some UUID.

PostCSS plugin file (PostCSS v8+):

// insert-comment-plugin.js
module.exports = (opts = {}) => {
  return {
    postcssPlugin: "postcss-prepend-comment",
    Root(root, postcss) {
      root.prepend(`/* ${opts.comment} */`);
    },
  };
};
module.exports.postcss = true;

PostCSS config file
//`postcss.config.js`
const insertComment = require("insert-comment-plugin");
module.exports = {
  plugins: [insertComment({ comment: "my-account-page" })],
};

PostCSS config file:

// postcss.config.js
const insertComment = require("insert-comment-plugin");
module.exports = {
  plugins: [insertComment({ comment: "my-account-page" })],
};

Second, a script running MutationObserver to track changes in document.head:

export function DevStyles({ webComponentId }) {
  const [container, setContainer] = useState(null);

  useEffect(() => {
    if (!container) {
      return;
    }

    const sourceStylesContainer = document.head;

    const tagIdComment = `/* ${webComponentId} */`;

    const moveStyles = () => {
      container.innerHTML = "";
      container.append(
        ...Array.from(sourceStylesContainer.children)
          .filter(x => x instanceof HTMLStyleElement)
          .filter((x) => x.textContent?.includes(tagIdComment)) // finds all <style> tags containing your web component ID
          .map((x) => x.cloneNode(true)) // copies styles into the current instance of your web component. You might need to have multiple instances, so all of them need to track the styles.
      );
    };

    const observer = new MutationObserver(moveStyles);
    observer.observe(sourceStylesContainer, {
      characterData: true,
      childList: true,
      subtree: true,
    });
    moveStyles();
    return () => {
      observer.disconnect();
    };
  }, [container]);

  return <div ref={setContainer}></div>;

const Root = () => {
  // here will also be your prod CSS fetching logic
  return (
    <>
      <DevStyles webComponentId="my-account-page" />
      <App />
    </>
  );
}

Finally, in Chrome Dev Tools you will see your web component rendered like this:

<my-account-page>
    #shadow-root
    <div>
      <style type="text/css">
        /* my-account-page */
        .confirmButton_fwi4g3 {

That is it, your web component should now correctly apply CSS and not leak it outside.

To wrap my react app into a Web Component I used react-to-webcomponent package. It has quirks but is pretty simple to set up.

Alternatives to CSS files

If you are choosing your CSS tech, you might want to look into CSS-in-JS solutions, like Styled Components, Emotion, etc. They usually provide a way to directly inject CSS into your react components with a simple API, completely avoiding the mess with CSS assets. For example, in Emotion you can configure a DOM element you want your CSS to be injected into. User CacheProvider component with its container prop.


Other articles

Learn ESLint concepts, not rules
From our blog
to your inbox.
We care about protecting your data. Here's our Privacy Policy.
newsletter