I recently created a React web app that’s meant to be embedded inside of a much larger host web app. One concern that I had in mind was how we could prevent any sort of unexpected interaction of our CSS styles. This concern applies both directions: I didn’t want the guest app changing the appearance of the host, and I didn’t want ongoing development in the host application to accidentally mis-style the React micro-frontend.

To solve this problem, I turned to the shadow DOM.

In case you’re not familiar with the shadow DOM, one of its intended purposes is to encapsulate and provide a boundary that CSS should not cross. It seemed perfect for this use case. And it worked out — well!

Of course, there are caveats to this approach and a few things that I wish I’d understood earlier on. I’m sharing these here in case this knowledge is of benefit to anyone else in the future.

Web Components aren’t necessary

The shadow DOM is frequently discussed in relation to Web Components. In fact, MDN’s shadow DOM documentation is part of their Web Component documentation.

Due to this, it’s easy to find yourself with the presumption that you need to create a custom Web Component to house the shadow DOM that you’ll mount your micro-frontend app inside of.

Although it won’t hurt anything to do so, it also isn’t necessary. Any element can have a shadow root attached. You can skip the hassle of defining a custom component and just do something like this:

// #guest-app-container is just a div:
const container = document.getElementById("guest-app-container");
const shadowRoot = container.attachShadow({ mode: "open" });
const appNode = document.createElement("div");
shadowRoot.append(appStylesElement); //more on this later
shadowRoot.append(appNode);
const root = ReactDOM.createRoot(appNode);
root.render(<GuestReactApp />);

Use an open shadow root

A closed shadow root will prevent external JavaScript from accessing its internal nodes. If this is of benefit to you, I’d be willing to bet that your host application is doing something truly unruly.

You almost certainly can just leave the shadow root open, and, if you do, this can simplify the logistics of loading the micro-frontend javascript code.

Consider the case where you have code-splitting, async chunk loading, or dynamic imports. Tooling that implements this will often assume it can place the dynamically loaded code in the document <head>. When you combine this with a closed shadow root, you’re setting yourself up for a bad time.

Your CSS belongs inside the shadow root

Okay, this one is pretty obvious, but I think it’s worth pointing out anyway. Thanks to the shadow DOM, our guest micro-frontend will be isolated from the CSS of the host application. This means that any styles you do want to take effect within your guest micro-frontend must be inserted inside your shadow DOM.

Some frameworks (such as Ember) and the vast majority of build tools (in my case, Vite) will make the assumption that your app will own the whole page, and that they can simply go inserting style links or style tags into the <head> of your document.

Whatever opinions your tools may have, the process of loading the JS and CSS for your embedded micro-frontend is going to be quite different than loading up an index.html file.

In my case, I was able to write a small Vite plugin that wrote out a JSON file enumerating our top-level JS and CSS files. The guest app then loaded this manifest and created <link> tags within the shadow root. Pretty easy.

CSS-in-JS

Although I’d like to sidestep the question of whether CSS-in-JS is a good idea, you may find yourself in the situation where your guest micro-frontend app, or one of its dependencies, makes use of it.

This is essentially another variant of the same problems discussed in the sections above — i.e., where are these dynamically created tags going to be inserted in the DOM? Once again, this kind of tooling normally prefers to place these <style> tags in the document <head>, and you need to make sure that you can find a way to override that.

Maybe this will be easy. For example, if you’re using emotion’s react bindings, then you can just put a <CacheProvider> at the top of your component tree that specifies a container DOM node that’s within your shadow root.

It could very well turn out to be something that your CSS-in-JS library will fight you on, though, and you should do the research up front.

The CSS isolation isn’t perfect

Depending on your perspective, you may or may not be surprised to learn that the shadow DOM does not provide a perfect, completely isolated boundary between its contents and the rest of the document.

During the process of developing and deploying the micro-frontend, a few such cases were encounted.

CSS Variables cross the boundary

Styles within the shadow root are free to reference any CSS variables that may be defined in the outside document.

Depending on your perspective, perhaps this is of benefit. It certainly serves as a feasible mechanism for providing some small level of customization to the guest micro-frontend.

If isolation is desired, though, the solution is easy. Just ensure that your app defines all the CSS variables it uses.

REMs are controlled by the host

If you stop to think about it, of course the rem unit will always be relative to… the root <html> element. This meaning does not change inside the shadow DOM.

Despite this, I was still caught off-guard. The lesson: if you’d like to use rem units, make sure your host and guest apps expect the same root font size.

The :root selector is useless inside your shadow DOM

Another variation on the same theme: there is no :root element inside your shadow DOM. You’ll need to ensure that neither the guest micro-frontend or its library dependencies try to apply CSS styles to the :root selector.

If your organization has a design system in place that makes use of :root or rems, get ready to make some pull request.

Conclusion

When embedding a micro-frontend inside a monolithic host application, it’s a worthwhile goal to have some protection that keeps your two applications’ CSS from interfering with each other. I certainly would not be eager to track down cross-application styling regressions.

The shadow DOM can be quite an effective tool here, but there are some caveats, and you will need to be prepared to solve some problems along the way.

Certainly the biggest risks come from your tooling. If your CSS-in-JS library insists on inserting <style> tags only in the document <head>, then this approach may not be for you. Similarly, you should ensure that your framework of choice doesn’t make any incompatible assumptions about its ownership of the whole document.