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-root
9 <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 }, []);
9
10 return (
11 <>
12 {cssUrls?.map((url) => (
13 <link
14 type="text/css"
15 rel="stylesheet"
16 href={url}
17 onError={incrementLinkCount}
18 onLoad={incrementLinkCount}
19 />
20 ))
21 )}
22 </>
23 );
24}
25
26const Root = () => {
27 // <App> component would ideally only render
28 // 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}
37
38async 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.js
2module.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;
11
12PostCSS config file
13//`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.js
2const 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);
3
4 useEffect(() => {
5 if (!container) {
6 return;
7 }
8
9 const sourceStylesContainer = document.head;
10
11 const tagIdComment = `/* ${webComponentId} */`;
12
13 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 ID
19 .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 };
22
23 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]);
34
35 return <div ref={setContainer}></div>;
36
37const Root = () => {
38 // here will also be your prod CSS fetching logic
39 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-root
3 <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.