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
- Used styled-components for managing styles in the app.
- A small counter app to make sure event handlers work inside shadow DOM.
- 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.
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
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:
1// get our shadow host2const host = document.querySelector('div.app-container');3// create a shadowRoot4const shadow = host.attachShadow({ mode: 'open' });56// Add other HTML nodes to the shadow Root7const 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.
1<body>2 <div id="react-app"></div>3</body>
Copied!
The simplest way would be to
- Create a Shadow Root inside
#react-app
, making the#react-app
our Shadow Host. - Create another element (a
div
orsection
) inside the Shadow Root. - Ask ReactDOM to render our application inside the element we've created in step 2.
Let's see the code.
1import React from 'react';2import { render } from 'react-dom';3import App from './App';45// get our shadow HOST6const host = document.querySelector('#react-app');7// create a shadow root inside it8const shadow = host.attachShadow({ mode: 'open' });9// create the element where we would render our app10const renderIn = document.createElement('div');11// append the renderIn element inside the shadow12shadow.appendChild(renderIn);13// Now render the application in the slow14render(<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.
- By adding regular
style
tag with internal stylesheets. - 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:
- We wrap our whole app inside the
StyleSheetManager
component. - The
target
we provide toStyleSheetManager
must be a static DOM node, inside the Shadow Root, not created or managed by React. - 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.
1import React from 'react';2import { render } from 'react-dom';3import { StyleSheetManager } from 'styled-components';45import App from './App';67// get our shadow HOST8const host = document.querySelector('#react-app');910// create a shadow root inside it11const shadow = host.attachShadow({ mode: 'open' });1213// create a slot where we will attach the StyleSheetManager14const styleSlot = document.createElement('section');15// append the styleSlot inside the shadow16shadow.appendChild(styleSlot);1718// create the element where we would render our app19const renderIn = document.createElement('div');20// append the renderIn element inside the styleSlot21styleSlot.appendChild(renderIn);2223// render the app24render(25 <StyleSheetManager target={styleSlot}>26 <App />27 </StyleSheetManager>,28 renderIn29);
Copied!
The logic above, creates a DOM structure like this
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:
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.
1import { render } from 'react-dom';2import { StyleSheetManager } from 'styled-components';34import App from './App';5import './global-style.css';67// get our shadow HOST8const host = document.querySelector('#react-app');910// the root element is a shadow, so we can do this11console.log(host.shadowRoot);1213// Now find the element where we will instruct styled-components to render the styles14const styleSlot = host.shadowRoot.querySelector('#react-app-root');15// Find the element where we will render the application16const renderIn = host.shadowRoot.querySelector('#react-app-slot');1718// call the render19// in reality we will call something like hydrate20render(21 <StyleSheetManager target={styleSlot}>22 <App />23 </StyleSheetManager>,24 renderIn25);
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.
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:
1useEffect(() => {2 if (isOpen) {3 // since we are dealing with shadow root, we have to be a little clever4 // when clicked anywhere inside the shadow root, the event.target would5 // be the shadow root itself.6 // If that is the case, then from window perspective, we don't do anything7 const isTargetInDropdown = (event: MouseEvent) => {8 // if the target is not in document body or shadow body9 // then we assume it is in the dropdown10 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 checking30 if (isTargetInDropdown(event)) {31 return;32 }3334 closePortal();35 };36 // Now from shadowroot, it will have regular stuff37 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.
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}1415/**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)/;2526 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 }3839 arr.push(window);4041 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.