How to render react applications in Shadow DOM with SSR and Style Encapsulation

Published on September 14, 2021 • 4m read

Learn how to properly render a ReactJS application inside Shadow DOM with Server Side Rendering. Also see Style Encapsulation with Styled Components.

Rendering React App in Shadow DOM with Style Encapsulation and SSR

Image by jplenio from Pixabay.

As developing the WPEForm Plugin I wanted to have a way to make sure the default styles of the form always work, no matter what theme my customers are using. CSS specificity issues across WordPress Plugins and Themes is not something new and there are many guides and recommendations to avoid that. But the real world is far from perfect and we have to make do with what we have.

A few years back I came across the new concept of Web components and Shadow DOM . At that time the browser support was quirky and very narrow. Luckily we are in 2021 now and that's not the case anymore.

So in this blog post, I will try to explain how I have setup rendering my react application inside a Shadow DOM with support for Server Side Rendering.

I am going to assume that you are familiar with Shadow DOM technology and terminologies (although I will provide some quick notes). If you are not, I recommend reading the MDN documentation first.

TL;DR Version

Here's a codesandbox demo where we've

  1. Used styled-components for managing styles in the app.
  2. A small counter app to make sure event handlers work inside shadow DOM.
  3. Pseudo markup in the index.html file to show how we can use declarative shadow DOM for SSR.

Check the file src/index.js to see how we've rendered. Also the file public/index.html has pseudo server side rendered markup for declarative shadow DOM.

Understanding Shadow DOM technology

In a very simple term, a shadow dom is nothing but another HTML element inside our regular DOM. The exception is, it has complete CSS encapsulation. Meaning the styles defined inside the Shadow DOM (with style tags or link tags as we will see later) do not alter the page style outside and vice versa.

A look at the Chrome DevTool will reveal something like this.

Chrome DevTool Shadow DOM
Chrome DevTool Shadow DOM

Where the highlighted portion is the Shadow DOM. Please be aware of the terminologies of shadow DOM:

  • Shadow host: The regular DOM node that the shadow DOM is attached to.
  • Shadow tree: The DOM tree inside the shadow DOM.
  • Shadow boundary: the place where the shadow DOM ends, and the regular DOM begins.
  • Shadow root: The root node of the shadow tree.

Creating a Shadow Root programmatically

To create and attach a shadow root, we first need a regular DOM node. This is called Shadow host. Let's say our markup is

[html]
1<div class="content">
2 <p>Here goes some content</p>
3 <div class="app-container"></div>
4</div>

Copied!

We plan to render our react application inside the highlighted line div.app-container. This is where we will create a shadow dom and the element div.app-container will become the Shadow host.

Without worrying about React or any other libraries/framework, if we were to create a Shadow Root and attach it to the host, we would go like this:

[js]
1// get our shadow host
2const host = document.querySelector('div.app-container');
3// create a shadowRoot
4const shadow = host.attachShadow({ mode: 'open' });
5
6// Add other HTML nodes to the shadow Root
7const para = document.createElement('p');
8shadow.appendChild(para);

Copied!

Rendering a React Application inside a Shadow DOM

Now that we know how to get/set/navigate through a Shadow DOM (much like regular DOM) we can use that to render our react application. For now, let's keep the SSR aspect aside.

Consider the following markup where we are supposed to render our application.

[html]
1<body>
2 <div id="react-app"></div>
3</body>

Copied!

The simplest way would be to

  1. Create a Shadow Root inside #react-app, making the #react-app our Shadow Host.
  2. Create another element (a div or section) inside the Shadow Root.
  3. Ask ReactDOM to render our application inside the element we've created in step 2.

Let's see the code.

[jsx]
1import React from 'react';
2import { render } from 'react-dom';
3import App from './App';
4
5// get our shadow HOST
6const host = document.querySelector('#react-app');
7// create a shadow root inside it
8const shadow = host.attachShadow({ mode: 'open' });
9// create the element where we would render our app
10const renderIn = document.createElement('div');
11// append the renderIn element inside the shadow
12shadow.appendChild(renderIn);
13// Now render the application in the slow
14render(<App />, renderIn);

Copied!

The above will work fine for the application itself, but you will find that the styles are lost. The reason is, styles are added to the HTML page by default. This will not affect the style of the elements rendered in the shadow.

Adding Styles in React App inside Shadow DOM

Shadow DOM allows adding styles through two methods.

  1. By adding regular style tag with internal stylesheets.
  2. By adding regular link tag with external stylesheets.

Both methods work fine and there are several ways to do them inside a shadow DOM. But since we are using React anyway, we can use one of many popular CSS-in-JS libraries to ease this up.

Using Styled Components inside Shadow DOM

Since we are using styled-components for our Plugin, our guide will focus on it.

By default styled-components will render the styles inside the head tag of the page. But this can be changed with the use of StyleSheetManager API.

There are a few things we need to make sure:

  1. We wrap our whole app inside the StyleSheetManager component.
  2. The target we provide to StyleSheetManager must be a static DOM node, inside the Shadow Root, not created or managed by React.
  3. There must be only one child inside StyleSheetManager.

To satisfy the above conditions, we need to change the markup a little bit where we render our app.

[jsx]
1import React from 'react';
2import { render } from 'react-dom';
3import { StyleSheetManager } from 'styled-components';
4
5import App from './App';
6
7// get our shadow HOST
8const host = document.querySelector('#react-app');
9
10// create a shadow root inside it
11const shadow = host.attachShadow({ mode: 'open' });
12
13// create a slot where we will attach the StyleSheetManager
14const styleSlot = document.createElement('section');
15// append the styleSlot inside the shadow
16shadow.appendChild(styleSlot);
17
18// create the element where we would render our app
19const renderIn = document.createElement('div');
20// append the renderIn element inside the styleSlot
21styleSlot.appendChild(renderIn);
22
23// render the app
24render(
25 <StyleSheetManager target={styleSlot}>
26 <App />
27 </StyleSheetManager>,
28 renderIn
29);

Copied!

The logic above, creates a DOM structure like this

Shadow DOM Structure for our app
Shadow DOM Structure

As you can see, the style tag responsible for styling the App, is inside the shadow.

Using SSR with Shadow DOM

Up until now, we've been seeing imperative shadow DOM rendering. But what if we want content from the server?

Luckily we now have Declarative Shadow DOM. You can read about here on web.dev .

While the actual setup for react to render and stream SSR content is out of the scope of this blog post, we will write some static markup to simulate how SSR would work with the Shadow DOM and our setup of styled-components.

Getting the SSR markup ready for Shadow DOM

Given the image before, the declarative markup of the shadow DOM would be this:

[html]
1<div id="react-app">
2 <!-- SSR of declarative Shadow DOM -->
3 <template shadowroot="open">
4 <!-- A Section inside the shadow root, used to inject styles by styled-components -->
5 <section id="react-app-root">
6 <!-- This is where we will render our react app -->
7 <!-- You can put styled-components generated style tag here -->
8 <div id="react-app-slot">
9 <!-- You can put rendered application here for SSR -->
10 </div>
11 </section>
12 </template>
13</div>

Copied!

Notice that previously we were creating the Shadow Root with host.attachShadow call. Now with the declarative Shadow DOM, we don't have to do that and already have access like this host.shadowRoot. So our rendering logic becomes even more simple.

[jsx]
1import { render } from 'react-dom';
2import { StyleSheetManager } from 'styled-components';
3
4import App from './App';
5import './global-style.css';
6
7// get our shadow HOST
8const host = document.querySelector('#react-app');
9
10// the root element is a shadow, so we can do this
11console.log(host.shadowRoot);
12
13// Now find the element where we will instruct styled-components to render the styles
14const styleSlot = host.shadowRoot.querySelector('#react-app-root');
15// Find the element where we will render the application
16const renderIn = host.shadowRoot.querySelector('#react-app-slot');
17
18// call the render
19// in reality we will call something like hydrate
20render(
21 <StyleSheetManager target={styleSlot}>
22 <App />
23 </StyleSheetManager>,
24 renderIn
25);

Copied!

Polyfill for Declarative Shadow DOM

As mentioned in the web.dev article, this concept is a proposed web platform feature and not all browsers support it. But we can polyfill it very easily with the code below.

[js]
1document.querySelectorAll('template[shadowroot]').forEach(template => {
2 const mode = template.getAttribute('shadowroot');
3 const shadowRoot = template.parentNode.attachShadow({ mode });
4 shadowRoot.appendChild(template.content);
5 template.remove();
6});

Copied!

Read more at web.dev .

Some drawbacks

With React 17's changes to event delegation rendering to Shadow DOM works flawlessly for any events managed by React.

However, for some libraries, like Drag and Drop, which rely on event listeners on the document, you may find them breaking.

So make sure to thoroughly test your application before using the shadow dom. Here in WPEForm, we are very careful about the implementation. One example would be our Dropdown component, which works both for regular render and render inside Shadow DOM. The event listener added to the document works something like this:

[ts]
1useEffect(() => {
2 if (isOpen) {
3 // since we are dealing with shadow root, we have to be a little clever
4 // when clicked anywhere inside the shadow root, the event.target would
5 // be the shadow root itself.
6 // If that is the case, then from window perspective, we don't do anything
7 const isTargetInDropdown = (event: MouseEvent) => {
8 // if the target is not in document body or shadow body
9 // then we assume it is in the dropdown
10 const target = event.target as HTMLElement;
11 if (
12 !document.body.contains(target) &&
13 container.current &&
14 !container.current.contains(target)
15 ) {
16 return true;
17 }
18 return (
19 event.target === dropdownMenuRef.current ||
20 dropdownMenuRef.current?.contains(event.target as any) ||
21 event.target === dropdownButtonRef.current ||
22 dropdownButtonRef.current?.contains(event.target as any)
23 );
24 };
25 const handlerWindow = (event: MouseEvent) => {
26 if ((event as any).target.shadowRoot) {
27 return;
28 }
29 // not a shadow root, so proceed with normal checking
30 if (isTargetInDropdown(event)) {
31 return;
32 }
33
34 closePortal();
35 };
36 // Now from shadowroot, it will have regular stuff
37 const handlerShadow = (event: MouseEvent) => {
38 if (isTargetInDropdown(event)) {
39 return;
40 }
41 closePortal();
42 };
43 const containerDom = container.current;
44 window.addEventListener('click', handlerWindow);
45 if (containerDom) {
46 containerDom.addEventListener('click', handlerShadow);
47 }
48 return () => {
49 window.removeEventListener('click', handlerWindow);
50 if (containerDom) {
51 containerDom.removeEventListener('click', handlerShadow);
52 }
53 };
54 }
55 return () => {};
56}, [closePortal, isOpen, container, dropdownButtonRef]);

Copied!

Here we add event listener to both window and a custom containerDom which is an HTMLElement event inside the shadowRoot but at the very top of the tree.

Similarly here's a couple of functions we use to determine scroll parents of an element, which accounts for shadowRoot in its path.

[ts]
1/**
2 * Get parent element of an element. Accounts for shadow root in path.
3 *
4 * @param element Current element node.
5 * @returns ParentNode or undefined.
6 */
7export function getParentElement(element: HTMLElement) {
8 let parent = element.parentElement;
9 if (parent && (parent as unknown as ShadowRoot).host) {
10 parent = (parent as unknown as ShadowRoot).host as HTMLElement;
11 }
12 return parent;
13}
14
15/**
16 * Get scroll parents of an element. Accounts for shadow root in path.
17 *
18 * @param element Current element.
19 * @returns Array of scroll parents.
20 */
21export function scrollParents(element: HTMLElement) {
22 let parent: HTMLElement;
23 const arr: Array<HTMLElement | Window> = [];
24 const overflowRegex = /(auto|scroll)/;
25
26 for (
27 parent = element;
28 parent !== document.body && parent != null;
29 parent = getParentElement(parent)!
30 ) {
31 const style = getComputedStyle(parent);
32 if (
33 overflowRegex.test(style.overflow! + style.overflowY! + style.overflowX!)
34 ) {
35 arr.push(parent);
36 }
37 }
38
39 arr.push(window);
40
41 return arr;
42}

Copied!


That's all I have discovered about React, Styled Components and Shadow DOM. I hope you've found it useful. Please consider giving a shoutout at twitter.

Start building beautiful forms!

Take the next step and get started with WPEForm today. You have the option to start with the free version, or get started with a trial. All your purchases are covered under 30 days Money Back Guarantee.