Cover by Shubham's Web3 on Unsplash
One of the questions I get asked the most is how to deal with CSS in micro frontends. After all, styling is always something that is needed for any UI fragment, however, it is also something that is globally shared and therefore a potential source of conflict.
In this article I want to go over the different strategies that exist to tame CSS and make it scale for developing micro frontends. If anything in here sounds reasonable to you then consider also looking into "The Art of Micro Frontends".
The code for the article can be found at github.com/piral-samples/css-in-mf. Make sure to check out the sample implementations.
Does the handling of CSS impact every micro frontend solution? Let's check the available types to validate this.
Types of Micro Frontends
In the past I've written a lot about what types of micro frontends exist, why they exist, and when what type of micro frontend architecture should be used. Going for the web approach implies using iframes for using UI fragments from different micro frontends. In this case, there are no constraints as every fragment is fully isolated anyway.
In any other case, independent of your solution uses client or server-side composition (or something in between) you'll end up with styles that are evaluated in the browser. Therefore, in all other cases you'll care about CSS. Let's see what options exist here.
No Special Treatment
Well, the first - and maybe most (or depending on the point of view, least) obvious solution is to not have any special treatment. Instead, each micro frontend can come with additional stylesheets that are then attached when the components from the micro frontend are rendered.
Ideally, each component only loads the required styles upon first rendering, however, since any of these styles might conflict with existing styles we can also pretend that all problematic styles are loaded when any component of a micro frontend renders.
The problem with this approach is that when generic selectors such as div
or div a
are given we'll restyle also other elements, not just the fragments of the originating micro frontend. Even worse, classes and attributes are no failsafe guard either. A class like .foobar
might also be used in another micro frontend.
You'll find the example for two conflicting micro frontends in the referenced demo repository at solutions/default
.
A good way out of this misery is to isolate the components much more - like web components.
Shadow DOM
In a custom element we can open a shadow root to attach elements to a dedicated mini document, which is actually shielded from its parent document. Overall, this sounds like a great idea, but like all the other solutions presented here there is no hard requirement.
Ideally, a micro frontend is free to decide how to implement the components. Therefore, the actual shadow DOM integration has to be done by the micro frontend.
There are some downsides of using the shadow DOM. Most importantly, while the styles inside the shadow DOM stay inside, global styles are also not impacting the shadow DOM. This seems like an advantage at first, however, since the main goal of this whole article is to only isolate the styles of a micro frontend, you might miss out requirements such as applying some global design system (e.g., Bootstrap).
To use the shadow DOM for styling we can either put the styles in the shadow DOM via a link
reference or a style
tag. Since the shadow DOM is unstyled and no styles from the outside propagate into it we'll actually need that. Besides writing some inline style we can also use the bundler to treat .css
(or maybe something like .shadow.css
) as raw text. This way, we'll get just some text.
For esbuild we can configure the pre-made configuration of piral-cli-esbuild
as follows:
module.exports = function(options) {
options.loader['.css'] = 'text';
options.plugins.splice(0, 1);
return options;
};
This removes the initial CSS processor (SASS) and configures a standard loader for .css
files. Now having something in the shadow DOM styled works like:
import css from "./style.css";
customElements.define(name, class extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.style.display = "contents";
const style = this.shadowRoot.appendChild(document.createElement('style'));
style.textContent = css;
}
});
The code above is a valid custom element that will be transparent from the styling perspective (display: contents
), i.e., only its contents will be reflected in the render tree. It hosts a shadow DOM that contains a single style
element. The content of the style
is set to the text of the style.css
file.
You'll find the example for two conflicting micro frontends in the referenced demo repository at solutions/shadow-dom
.
Another reason for avoiding shadow DOM for domain components is that not every UI framework is capable of handling elements within the shadow DOM. Therefore, an alternative solution has to be looked for anyway. One way is to fall back to using some CSS conventions instead.
Using a Naming Convention
If every micro frontend follows a global CSS convention then conflicts can be avoided on the meta level already. The easiest convention is to prefix each class with the name of the micro frontend. So, for instance, if one micro frontend is called shopping
and another one is called checkout
then both would rename their active
class to shopping-active
/ checkout-active
respectively.
The same can be applied to other potentially conflicting names, too. As an example, instead of having an ID like primary-button
we'd call it shopping-primary-button
in case of a micro frontend called shopping
. If, for some reason, we need to style an element we'd should use descendent selectors such as .shopping img
to style the img
tag. This now applies to img
elements within some element having the shopping
class. The problem with this approach is that the shopping micro frontend might also use elements from other micro frontends. What if we would see div.shopping > div.checkout img
? Even though img
is now hosted / integrated by the component brought through the checkout
micro frontend it would be styled by the shopping
micro frontend CSS. This is not ideal.
You'll find the example for two conflicting micro frontends in the referenced demo repository at https://github.com/piral-samples/css-in-mf/tree/main/solutions/default.
Even though naming conventions solve the problem up to some degree, they are still prone to errors and cumbersome to use. What if we rename the micro frontend? What if the micro frontend gets a different name in different applications? What if we forget to apply the naming convention at some points? This is where tooling helps us.
CSS Modules
One of the easiest ways to automatically introduce some prefixes and avoid naming conflicts is to use CSS modules. Depending on your choice of bundler this is either possible out of the box or via some config change.
// Import "default export" from CSS
import styles from './style.modules.css';
// Apply
<div className={styles.active}>Active</div>
The imported module is a generated module holding values mapping their original class names (e.g., active
) to a generated one. The generated class name is usually a hash of the CSS rule content mixed with the original class name. This way, the name should be as unique as possible.
As an example, let's consider a micro frontend constructed with esbuild
. For esbuild
you'd need a plugin (esbuild-css-modules-plugin
) and respective config change to include CSS modules.
Using Piral we only need to adjust the config already brought by piral-cli-esbuild
. We remove the standard CSS handling (using SASS) and replace it by the plugin:
const cssModulesPlugin = require('esbuild-css-modules-plugin');
module.exports = function(options) {
options.plugins.splice(0, 1, cssModulesPlugin());
return options;
};
Now we can use CSS modules in our code as shown above.
You'll find the example for two conflicting micro frontends in the referenced demo repository at solutions/css-modules
.
There are a couple of disadvantages that come with CSS modules. First, it comes with a couple of syntax extensions to standard CSS. This is necessary to distinguish between styles that we want to import (and therefore pre-process / hash) and styles that should remain as-is (i.e., to be consumed later on without any import). Another way is to bring the CSS directly in the JS files.
CSS-in-JS
CSS-in-JS has quite a bad reputation lately, however, I think this is a bit of misconception. I also prefer to call it "CSS-in-Components", because it brings the styling to the components itself. Some frameworks (Astro, Svelte, ...) even allow this directly via some other way. The often cited disadvantage is performance - which is usually reasoned by composing the CSS in the browser. This, however, is not always necessary and in the best case the CSS-in-JS library is actually build-time-driven, i.e., without any performance drawback.
Nevertheless, when we talk about CSS-in-JS (or CSS-in-Components for that matter) we need to consider the various options which are out there. For simplicity, I've only included three: Emotion, Styled Components, and Vanilla Extract. Let's see how they can help us to avoid conflicts when bringing together micro frontends in one application.
Emotion
Emotion is very cool library that comes with helpers for frameworks such as React, but without setting these frameworks as a prerequisite. Emotion can be very nicely optimized and pre-computed and allows us to use the full arsenal of available CSS techniques.
Using "pure" Emotion is rather easy; first install the package:
npm i @emotion/css
Now you can use it in the code as follows:
import { css } from '@emotion/css';
const tile = css`
background: blue;
color: yellow;
flex: 1;
display: flex;
justify-content: center;
align-items: center;
`;
// later
<div className={tile}>Hello from Blue!</div>
The css
helper allows us to write CSS that is parsed and placed in a stylesheet. The returned value is the name of the generated class.
If we want to work with React in particular we can also use the jsx
factory from Emotion (introducing a new standard prop called css
) or the styled
helper:
npm i @emotion/react @emotion/styled
This now feels a lot like styling is part of React itself. For instance, the styled
helper allows us to define new components:
const Output = styled.output`
border: 1px dashed red;
padding: 1rem;
font-weight: bold;
`;
// later
<Output>I am groot (from red)</Output>
In contrast, the css
helper prop gives us the ability to shorten the notation a bit:
<div css={`
background: red;
color: white;
flex: 1;
display: flex;
justify-content: center;
align-items: center;
`}>
Hello from Red!
</div>
All in all this generates class names which will not conflict and provide the robustness of avoiding a mixup of styles. The styled
helper in particular was inspired heavily from the popular styled-components
library.
You'll find the example for two conflicting micro frontends in the referenced demo repository at solutions/emotion
.
Styled Components
The styled-components
library is arguably the most popular CSS-in-JS solution and quite often the reason for the bad reputation of such solutions. Historically, it was really all about composing the CSS in the browser, but in the last couple of years they really brought that forward immensely. Today, you can have some very nice server-side composition of the used styles, too.
In contrast to emotion
the installation (for React) requires a few less packages. The only downside is that typings are an afterthought - so you need to install two packages for full TypeScript love:
npm i styled-components --save
npm i @types/styled-components --save-dev
Once installed, the library is already fully usable:
import styled from 'styled-components';
const Tile = styled.div`
background: blue;
color: yellow;
flex: 1;
display: flex;
justify-content: center;
align-items: center;
`;
// later
<Tile>Hello from Blue!</Tile>
The principle is the same as for emotion
. So let's explore another option that tries to come up with zero-cost from the beginning - not as an afterthought.
You'll find the example for two conflicting micro frontends in the referenced demo repository at solutions/styled-components
.
Vanilla Extract
What I wrote beforehand about utilizing types to be closer to the components (and avoiding unnecessary runtime costs) is exactly what is covered by the latest generation of CSS-in-JS libraries. One of the most promising libraries is @vanilla-extract/css
.
There are two major ways to use the library:
- Integrated with your bundler / framework
- Directly with the CLI
In this example we choose the former - with its integration to esbuild
. For the integration to work we need to use the @vanilla-extract/esbuild-plugin
package.
Now we integrate it in the build process. Using the piral-cli-esbuild
configuration we only need to add it to the plugins of the configuration:
const { vanillaExtractPlugin } = require("@vanilla-extract/esbuild-plugin");
module.exports = function (options) {
options.plugins.push(vanillaExtractPlugin());
return options;
};
For Vanilla Extract to work correctly we need to write .css.ts
files instead of the plain .css
or .sass
files. Such a file could look as follows:
import { style } from "@vanilla-extract/css";
export const heading = style({
color: "blue",
});
This is all valid TypeScript. What we'll get in the end is an export of a class name - just like we got from CSS modules, Emotion, ... - you get the point.
So in the end, the style above would be applied like this:
import { heading } from "./Page.css.ts";
// later
<h2 className={heading}>Blue Title (should be blue)</h2>
This will be fully processed at build-time - not runtime cost whatsoever.
You'll find the example for two conflicting micro frontends in the referenced demo repository at solutions/vanilla-extract
.
Another method that you might find interesting is to use a CSS utility library such as Tailwind.
CSS Utilities like Tailwind
This is a category on its own, but I thought since Tailwind is the dominant tool in this one I'll only present Tailwind. The dominance of Tailwind even goes so far that some people are asking questions like "do you write CSS or Tailwind?". This is quite similar to the dominance of jQuery in the DOM manipulation sector ca. 2010, where people asked "is this JavaScript or jQuery?".
Anyhow, using a CSS utility library has the advantage that styles are generated based on usage. These styles will not conflict as they are always defined in the same way by the utility library. So, each micro frontend will just come with the portion of the utility library that is necessary to display the micro frontend as desired.
In case of Tailwind and esbuild we'll also need to install the following packages:
npm i autoprefixer tailwindcss esbuild-style-plugin
The configuration of esbuild is a bit more complicated than beforehand. The esbuild-style-plugin
is essentially a PostCSS plugin for esbuild; so it must be configured correctly:
const postCssPlugin = require("esbuild-style-plugin");
module.exports = function (options) {
const postCss = postCssPlugin({
postcss: {
plugins: [require("tailwindcss"), require("autoprefixer")],
},
});
options.plugins.splice(0, 1, postCss);
return options;
};
Here, we remove the default CSS handling plugin (SASS) and replace it by the PostCSS plugin - using both, the autoprefixer
and the tailwindcss
extensions for PostCSS.
Now we need to add a valid tailwind.config.js file:
module.exports = {
content: ["./src/**/*.tsx"],
theme: {
extend: {},
},
plugins: [],
};
This is essentially the bare minimum for configuring Tailwind. It just mentions that the tsx
files should be scanned for usage of Tailwind utility classes. The found classes will then be put into the CSS file.
The CSS file therefore also needs to know where the generated / used declarations should be contained. As bare minimum we'll have only the following CSS:
@tailwind utilities;
There are other @tailwind
instructions, too. For instance, Tailwind comes with a reset and a base layer. However, in micro frontends we are usually not concerned with the these layers. This falls into the concern of an app shell or orchestrating application - not in a domain application.
The CSS is then replaced by classes that are already specified from Tailwind:
<div className="bg-red-600 text-white flex flex-1 justify-center items-center">Hello from Red!</div>
You'll find the example for two conflicting micro frontends in the referenced demo repository at solutions/tailwind
.
Comparison
Almost every method presented so far is a viable contender for your micro frontend. In general, these solutions can also be mixed. One micro frontend could go for a shadow DOM approach, while another micro frontend is happy with Emotion. A third library might opt-in for Vanilla Extract.
In the end, the only thing that matters is that the chosen solution is collision free and does not come with a (huge) runtime cost. While some methods are more efficient than others, they all provide the desired styling isolation.
Method | Migration Effort | Readability | Robustness | Performance Impact |
---|---|---|---|---|
Convention | Medium | High | Low | None |
CSS Modules | Low | High | Medium | None to Low |
Shadow DOM | Low to Medium | High | High | Low |
CSS-in-JS | High | Medium to High | High | None to High |
Tailwind | High | Medium | High | None |
The performance impact depends largely on the implementation. For instance, for CSS-in-JS you might get a high impact if parsing and composition if full done at runtime. If the styles are already pre-parsed but only composed at runtime you might have a low impact. In case of a solution like Vanilla Extract you would have essentially no impact at all.
For shadow DOM the main performance impact could be the projection or move of elements inside the shadow DOM (which is essentially zero) combined with the re-evaluation of a style
tag. This is, however, quite low and might even yield some performance benefits of the given styles are always to the point and dedicated only for a certain component to be shown in the shadow DOM.
In the example we had the following bundle sizes:
Method | Index [kB] | Page [kB] | Sheets [kB] | Overall [kB] | Size [%] |
---|---|---|---|---|---|
Default | 1.719 | 1.203 | 0.245 | 3.167 | 100% |
Convention | 1.761 | 1.241 | 0.269 | 3.271 | 103% |
CSS Modules | 2.149 | 2.394 | 0 | 4.543 | 143% |
Shadow DOM | 10.044 | 1.264 | 0 | 11.308 | 357% |
Emotion | 1.670 | 1.632 | 25.785 | 29.087 | 918% |
Styled Components | 1.618 | 1.612 | 63.073 | 66.303 | 2093% |
Vanilla Extract | 1.800 | 1.257 | 0.314 | 3.371 | 106% |
Tailwind | 1.853 | 1.247 | 0.714 | 3.814 | 120% |
Take these numbers with a grain of salt, because in case of Emotion and Styled Components the runtimes could (and presumably even should) be shared. Also the given example micro frontends have really been small (overall size being 3kB for all the UI fragments). For a much larger micro frontend the growth would certainly not be as problematic as sketched here.
The size increase of the shadow DOM solution can be explained by the simple utility script we provided for easily wrapping existing React renderings into the shadow DOM (without spawning a new tree). If such a utility is centrally shared then the size would be much closer to the other more lightweight solutions.
Conclusion
Dealing with CSS in a micro frontend solution does not need to be difficult - it just needs to be done in a structured and ordered way from the beginning, otherwise conflicts and problems will arise. In general, choosing solutions such as CSS modules, Tailwind, or a scalable CSS-in-JS implementation are advisable.
Top comments (8)
What about the use of inline-styles where applicable? They are a bit more verbose and will have some limitations (like not being able to use selectors like :hover), but as they are applied to the DOM elements directly, they will will avoid conflicts.
You need to use inline styles carefully, as they will overwrite ANY external definitions.
Yeah that is also applicable. Due to the mentioned constraints I've went with CSS utilities like Tailwind instead, but using inline styles would certainly also fit the list.
The problem with CSS is, that it has different targets:
Each new "CSS-framework" adds a new level of complexity ontop of the rules of CSS, which are already fairly complex. So even if they attempt to, they do not make our life easier.
Less CSS
I have used a number of "generative" frameworks like VanJS or DML, that create the whole DOM programmatically through JS/TS. Here you can chose to use CSS in the "traditional" way - or include inline styles to the generating function. Here is an example of a rounded button:
Here we use CSS, but do not get more complexity to the game.
The same applies to many other areas, where CSS starts to get tricky. If you want a "responsive design", you just write a function that applies different CSS rules depending on the device type. There are no special tools or selectors necessary. Again, less complexity.
This is a "CSS in JS"-approach, but without a special framework, just simple JS programming.
We found, that in general the resulting CSS was fairly reduced using this approach. CSS that applies to new types or single elements is generally bound as inline-style, superior CSS that is used for document formatting is given as a style sheet.
Maybe this could also be a solution for "micro-frontends", to encapsulate all "specific" CSS to the single task, but leave all superior formatting to the central app?
Stay away from ShadowDOM if you can. It's one of the most annoying thing I've dealt with in my life.
Hehe, yes as mentioned it is not without downsides.
Recently somebody asked me "what prevents us to just put all MF components automatically in a shadow DOM?".
Well, my response was "it's better to leave that decision to the teams. having your component magically moved to the shadow DOM will have a lot of side-effects and can be quite harmful. it's impossible for the app shell to predict what's inside a component and how these side-effects play out, so leaving this decision up to the teams is in my opinion necessary to ensure flexibility and predictability."
So I'd not be as extreme as "never use it", but I'd say that "use with care" is the right approach.
Yes you're right. ShadowDOM should be used in small components and we definitely should not put a whole large-scale project inside it. I used it for some special components, such as widgets or blog posts to embedded into other websites.
It was awesome. thanks for sharing this ^^
Сongratulations 🥳! Your article hit the top posts for the week - dev.to/fruntend/top-10-posts-for-f...
Keep it up 👍