DEV Community

Cover image for CSS in Micro Frontends
Florian Rappl
Florian Rappl

Posted on

CSS in Micro Frontends

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.

Conflict

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.

Shadow DOM

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;
};
Enter fullscreen mode Exit fullscreen mode

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;
  }
});
Enter fullscreen mode Exit fullscreen mode

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.

Convention

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.

CSS Modules

// Import "default export" from CSS
import styles from './style.modules.css';

// Apply
<div className={styles.active}>Active</div>
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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.

CSS-in-JS

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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",
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.

Tailwind

In case of Tailwind and esbuild we'll also need to install the following packages:

npm i autoprefixer tailwindcss esbuild-style-plugin
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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: [],
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
efpage profile image
Eckehard

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.

Collapse
 
florianrappl profile image
Florian Rappl

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.

Collapse
 
efpage profile image
Eckehard

The problem with CSS is, that it has different targets:

  • Changing the visual appearance of DOM element types (which was the initial target of CSS)
  • Changing the appearance and behavoir of single DOM elements regardless of their type (=> inline styles)
  • Changing the page layout
  • Adding a visual behavoir, that is more applied to the functional logic rather than the visual appearance All that resides in parallel to the functional logic introduced by HTML and JS.

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:

    // create a rounded button
    function rbutton(...args) {
      let r = button(...args)
      r.style.borderRadius = "50vh"   // Apply CSS
      return (r)
    }
    button("This is a button")
    rbutton("This is a rounded button")
Enter fullscreen mode Exit fullscreen mode

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?

Collapse
 
mangor1no profile image
Mangor1no

Stay away from ShadowDOM if you can. It's one of the most annoying thing I've dealt with in my life.

Collapse
 
florianrappl profile image
Florian Rappl

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.

Collapse
 
mangor1no profile image
Mangor1no

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.

Collapse
 
nicolasdanelon profile image
Nicolás Danelón

It was awesome. thanks for sharing this ^^

Collapse
 
fruntend profile image
fruntend

Сongratulations 🥳! Your article hit the top posts for the week - dev.to/fruntend/top-10-posts-for-f...
Keep it up 👍