24th October 2021 • 4 min read
Using CSS files in web components

Vladimir Zotov
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:
1import styles from './ConfirmButton.module.css';2...3const ConfirmButton = () => <button className={styles.confirmButton}>Ok</button>;4// It renders something like: <button class="confirmButton_fwi4g3">Ok</button*
And here is how that CSS was injected into DOM by the bundler:
1<html>2 <head>3 <style type="text/css">4 .confirmButton_fwi4g3 {5 ...6 </style>7 <my-microfrontend>8 #shadow-root9 <button className="confirmButton_fwi4g3">Ok</button>10 </my-microfrontend>11 </head>12</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
1{2 "index.html": {3 "file": "assets/index.e901b340.js",4 "src": "index.html",5 "isEntry": true,6 "imports": ["_vendor.d74ea55b.js"],7 "css": ["assets/index.08fbfe68.css"]8 },9 "_vendor.d74ea55b.js": {10 "file": "assets/vendor.d74ea55b.js",11 "css": ["assets/vendor.a9bef22f.css"]12 }13}
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:
1export function ProdStyles() {2 const [cssUrls, setCssUrls] = useState(null);3 useEffect(() => {4 const assetManifestUrl = new URL('manifest.json', import.meta.url).toString();5 getAssetsFromWebpack4AssetManifest(assetManifestUrl).then((assets) => {6 setCssUrls(assets.cssUrls);7 });8 }, []);910 return (11 <>12 {cssUrls?.map((url) => (13 <link14 type="text/css"15 rel="stylesheet"16 href={url}17 onError={incrementLinkCount}18 onLoad={incrementLinkCount}19 />20 ))21 )}22 </>23 );24}2526const Root = () => {27 // <App> component would ideally only render28 // after all of the CSS assets were loaded,29 // but I omitted the check for simplicity.30 return (31 <>32 <ProdStyles />33 <App />34 </>35 );36}3738async function getAssetsFromWebpack4AssetManifest(baseUrl) {39 const getAbsoluteUrl = path => new URL(path, baseUrl).toString();40 const response = await fetch(getAbsoluteUrl('/asset-manifest.json'));41 if (!response.ok) {42 throw new Error('Failed to get manifest');43 }44 const manifest = await response.json();45 const relativeCssUrls = manifest.entrypoints.filter(x => x.endsWith('.css'));46 return {47 cssUrls: relativeCssUrls.map(getAbsoluteUrl),48 };49}
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+):
1// insert-comment-plugin.js2module.exports = (opts = {}) => {3 return {4 postcssPlugin: "postcss-prepend-comment",5 Root(root, postcss) {6 root.prepend(`/* ${opts.comment} */`);7 },8 };9};10module.exports.postcss = true;1112PostCSS config file13//`postcss.config.js`14const insertComment = require("insert-comment-plugin");15module.exports = {16 plugins: [insertComment({ comment: "my-account-page" })],17};
PostCSS config file:
1// postcss.config.js2const insertComment = require("insert-comment-plugin");3module.exports = {4 plugins: [insertComment({ comment: "my-account-page" })],5};
Second, a script running MutationObserver to track changes in document.head:
1export function DevStyles({ webComponentId }) {2 const [container, setContainer] = useState(null);34 useEffect(() => {5 if (!container) {6 return;7 }89 const sourceStylesContainer = document.head;1011 const tagIdComment = `/* ${webComponentId} */`;1213 const moveStyles = () => {14 container.innerHTML = "";15 container.append(16 ...Array.from(sourceStylesContainer.children)17 .filter(x => x instanceof HTMLStyleElement)18 .filter((x) => x.textContent?.includes(tagIdComment)) // finds all <style> tags containing your web component ID19 .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.20 );21 };2223 const observer = new MutationObserver(moveStyles);24 observer.observe(sourceStylesContainer, {25 characterData: true,26 childList: true,27 subtree: true,28 });29 moveStyles();30 return () => {31 observer.disconnect();32 };33 }, [container]);3435 return <div ref={setContainer}></div>;3637const Root = () => {38 // here will also be your prod CSS fetching logic39 return (40 <>41 <DevStyles webComponentId="my-account-page" />42 <App />43 </>44 );45}
Finally, in Chrome Dev Tools you will see your web component rendered like this:
1<my-account-page>2 #shadow-root3 <div>4 <style type="text/css">5 /* my-account-page */6 .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.

