🐝 Tjoskar's Blog

I write about stuff, mostly for myself

Shadow DOM in React

This might not be an everyday use case, but sometimes it can be good to encapsulate a react component from the rest of the react tree.

Let’s say you have the following components:

// blue.tsx
import './blue.css';

export const Blue = () => <h1>Hello Blue!</h1>;
// blue.css
h1 {
  color: blue;
}
// green.tsx
import './green.css';
export const Green = () => <h1>Hello Green!</h1>;
// green.css
h1 {
  color: green;
}

What color will the text have if you mount the components like this:

import { render } from 'react-dom';
import { Blue } from './blue';
import { Green } from './green';

render(
  <div>
    <Blue />
    <Green />
  </div>,
  document.getElementById('root')
);

And will it differs if you swap the imports?

Both texts will have the same color, and it simply depends on in what order the css was added in the dom. The latest wins:

export default function App(): JSX.Element {
  return <h1>Hello world</h1>
}


You might now think: “Yeah, but this is only a problem when the css is injected in the global stylesheet. We don’t have this problem with css-in-js or css modules.”

Or do we? What happens if we add this to our css file:

/* global.css */
h1 { color: red !important; }
<link rel="stylesheet" href="global.css">

The global css will still overwrite any css that you have declared in css-in-js or css modules. So what should we do if we want to render a component in total style isolation? It’s here where shadow dom steps in.

Let’s start simple, without React. element.attachShadow() lets you create an isolated node inside the normal DOM tree. Here’s a simple example:

const appDiv = document.getElementById('app');

let shadow = appDiv.attachShadow({ mode: 'open' });

shadow.innerHTML = `
<style>
  h1 { color: red; }
</style>
<h1>Can't touch this</h1>
`;

No matter what we add in the global stylesheet, it will not affect the text “Can’t touch this”. And no matter what we add in the shadow DOM, it will not affect the text “My nice site”.

How can we use this together with React?

Easy — let’s create a Shadow DOM component:

const ShadowDom: FunctionComponent = ({ children }) => {
  // A ref to the DOM node where we want to attach the shadow DOM
  const node = useRef(null);
  // The shadow DOM itself
  const [shadowNode, setShadowNode] = useState(null);

  useEffect(() => {
    if (node.current) {
      // Creates a shadow DOM:
      const root = node.current.attachShadow({ mode: 'open' })
      setShadowNode(root);
    }
  }, []);

  return (
    <Fragment>
      <div ref={node} />
      {/* We are creating a portal here to render our children in the shadow DOM */}
      {shadowNode && createPortal(children, shadowNode)}
    </Fragment>
  );
};

And then use it:

root.render(
    <>
        <ShadowDom>
          <Blue />
    </ShadowDom>
    <Blue />
    <Green />
  </>,
    document.getElementById('root')
);

The result will be:

<h1 style="color: blue;">Hello Blue!</h1>
<h1 style="color: green;">Hello Blue!</h1>
<h1 style="color: green;">Hello Green!</h1>

You can see a full example here: https://stackblitz.com/edit/react-ts-e2ouoa?file=index.tsx

Shadow DOM + CSS-in-JS (with Emotion)

How can we use this with CSS-in-JS? Let’s take Emotion as an example (but the same technique works with e.g. styled-components).

With Emotion, we can create our own style cache. See @emotion/cache and CacheProvider for more information. This allows you to control how styles get inserted by Emotion.

Let’s rewrite our ShadowDom component to create our own cache:

const ShadowDom: FunctionComponent = ({ children }) => {
  // A ref to the DOM node where we want to attach the shadow DOM
  const node = useRef(null);
  // The shadow DOM itself
  const [shadowNode, setShadowNode] = useState(null);
  // Emotion cache
  const [cacheNode, setCacheNode] = useState(null);

  useEffect(() => {
    if (node.current) {
      // Creates a shadow DOM:
      const root = node.current.attachShadow({ mode: 'open' })
      setShadowNode(root);
      setCacheNode(
        createCache({
          container: root,
          key: 'shadow'
        })
      );
    }
  }, []);

  return (
    <Fragment>
      <div ref={node} />
      {/* We are creating a portal here to render our children in the shadow DOM */}
      {shadowNode &&
        createPortal(
          <CacheProvider value={cacheNode}>{children}</CacheProvider>,
          shadowNode
        )}
    </Fragment>
  );
};

And here we have it — a way to render a component in full style isolation.

You can try it out here: https://stackblitz.com/edit/react-ts-vxyifz?file=index.tsx

Dragon Plane