DEV Community

Coleman Imhoff for CompanyCam

Posted on

Build an Embeddable Widget using Preact and the Shadow DOM

Our team at CompanyCam was tasked with building a widget that our users could embed on their websites. The widget needed to be easy to install, responsive, and provide a fullscreen application experience. This article introduces and explains the technical decisions made and how we got there.

Discovery

Before jumping into the code, I want to quickly discuss some things our team learned during discovery. Hopefully, this will assist you in making the right decisions for your project.

After learning about the details of the product, we found that the codebase had two requirements.

Encapsulation

Our team needed to prevent external CSS from cascading into our code. In addition, our styling needed to be scoped to our application. We explored wrapping the widget in an iFrame, which provides a nested browsing context.  This offered the encapsulation we needed, but we found it difficult to control the iFrame in order to provide a quality fullscreen experience. The Fullscreen API was a potential solution, but it did not hold the required browser support. Using an iFrame to encapsulate a smaller product could be a great solution, but did not fit our use case.

We turned our attention to the Shadow DOM API. The Shadow DOM provides a way to attach a hidden DOM tree to any element. This creates encapsulation, but doesn't limit your ability to have control of the application. In addition, the Shadow DOM API has good browser support.

Small Bundle

It's imperative that the widget loads quickly. With the strategy the team had in place, it was clear that it was going to be difficult to code-split our application. At CompanyCam, engineers write user interfaces in React therefore it made sense to stick with that.

As we added 3rd party libraries, our bundle size grew. We found that Preact was a good solution to this problem. It provides all the same features as React, but in a much smaller package. You can compare the unpacked size of Preact to a combined React and React-DOM and see a significant difference!

Now, let's jump into some code! Feel free to clone this starter repo if a working example is helpful for you.

Mounting Your App with a Shadow DOM Layer

Preact is easy to integrate into an existing project. Mounting our Preact App component should look similar to React.

/* @jsx h */
import { h, render } from "preact";

import App from "./components/App.jsx";

const appRoot = document.querySelector("#app-root");

render(<App />, appRoot);
Enter fullscreen mode Exit fullscreen mode

Now let's add a Shadow DOM layer.

/* @jsx h */
import { h, render } from "preact";

import App from "./components/App.jsx";

// app shadow root

const appRoot = document.querySelector("#app-root");
appRoot.attachShadow({
  mode: "open",
});

render(<App />, appRoot.shadowRoot);
Enter fullscreen mode Exit fullscreen mode

We can attach a Shadow DOM layer to a regular DOM node, called a shadow host. We can do this by calling the attachShadow method, which takes options as a parameter. Passing mode with the value open allows the shadow DOM to be accessible through the shadowRoot property. The other value for mode is closed, which results in shadowRoot returning null.

To verify things are in working order, we can open our
browser's developer tools and and look at the DOM tree. Here, we can see our Shadow DOM layer.

Screen Shot 2021-09-30 at 2.01.52 PM-1

Styling the Shadow DOM

Styles must be scoped inside the Shadow DOM in order to render.

/* @jsx h */
import { h, render } from "preact";

import App from "./components/App.jsx";

import styles from "./styles.css";

// app shadow root

const appRoot = document.querySelector("#app-root");
appRoot.attachShadow({
  mode: "open",
});

// inject styles

const styleTag = document.createElement("style");
styleTag.innerHTML = styles;
appRoot.appendChild(styleTag);

render(<App />, appRoot.shadowRoot);
Enter fullscreen mode Exit fullscreen mode

If you're using webpack, keep in mind you will need css-loader in order for this approach to work. Create a style tag and set its innerHTML to an imported stylesheet.

Screen Shot 2021-10-07 at 10.44.27 AM

As our application grew, managing our styles became cumbersome and our team wanted to find another solution. At CompanyCam, our designers enjoy designing our products with styled-components. With styled-components, a generated stylesheet is injected at the end of the head of the document. Due to our Shadow DOM layer, this won't work without some configuration.

/* @jsx h */
import { h } from "preact";
import styled, { StyleSheetManager } from "styled-components";

const Heading = styled.h3`
  color: #e155f5;
  font-family: sans-serif;
`;

const App = () => {
  return (
    <StyleSheetManager target={document.querySelector("#app-root").shadowRoot}>
        <Heading>Hey, Shadow DOM!</Heading>
    </StyleSheetManager>
  );
};

export default App;
Enter fullscreen mode Exit fullscreen mode

The StyleSheetManager helper component allows us to modify how styles are processed. Wrap it around the App component's children and pass the shadowRoot of the shadow host as the value of target. This provides an alternate DOM node to inject styles into.

Just like the previous technique, we can see our styles scoped within the Shadow DOM.

Screen Shot 2021-10-07 at 10.53.48 AM

Avoid Inheritance

The Shadow DOM will prevent outside CSS selectors from reaching any contained markup. But, it is possible for elements in Shadow DOM to inherit CSS values. We can reset properties to their default values by declaring the the property all to the value initial on the parent element of your application.

/* @jsx h */
import { h } from "preact";
import styled, { StyleSheetManager } from "styled-components";

const Heading = styled.h3`
  color: #e155f5;
  font-family: sans-serif;
`;

const WidgetContainer = styled.div`
  all: initial;
`;

const App = () => {
  return (
    <StyleSheetManager target={document.querySelector("#app-root").shadowRoot}>
      <WidgetContainer>
        <Heading>Hey, Shadow DOM!</Heading>
      </WidgetContainer>
    </StyleSheetManager>
  );
};

export default App;
Enter fullscreen mode Exit fullscreen mode

Win the Stacking Order Battle with Portals

Whether it's Wordpress, Squarespace, Wix, or something from scratch, our widget needed to live on any website. Since stacking order depends on the DOM tree hierarchy, we immediately saw z-index issues in our fullscreen components.

Screen Shot 2021-10-07 at 11.57.11 AM

Portals provide a way to render children into a DOM node which exists outside the context of the application. You can mount your Portal to any DOM node. In our case, we needed to render these fullscreen components as high in the DOM tree as possible. Therefore, we can append our Portal to the body of the document we are installing the widget on.

Screen Shot 2021-10-07 at 11.57.17 AM

Let's create our Portal by starting at the root of our application.

// index.js

/* @jsx h */
import { h, render } from "preact";

import App from "./components/App.jsx";

// shadow portal root

const portalRoot = document.createElement("div");
portalRoot.setAttribute("id", "portal-root");
portalRoot.attachShadow({
  mode: "open",
});
document.body.appendChild(portalRoot);

// app shadow root

const appRoot = document.querySelector("#app-root");
appRoot.attachShadow({
  mode: "open",
});

render(<App />, appRoot.shadowRoot);
Enter fullscreen mode Exit fullscreen mode

Create a shadow host for the Portal component and give it an id. Then, just like we did with appRoot, attach a new Shadow DOM layer.

/* @jsx h */

import { h, Fragment } from "preact";
import { useLayoutEffect, useRef } from "preact/hooks";
import { createPortal } from "preact/compat";
import styled, { StyleSheetManager } from "styled-components";

const Portal = ({ children, ...props }) => {
  const PortalContainer = styled.div`
    all: initial;
  `;

  const node = useRef();
  const portalRoot = document.querySelector("#portal-root");

  useLayoutEffect(() => {
    const { current } = node;
    if (current) {
      portalRoot.appendChild(current);
    }
  }, [node, portalRoot]);

  return (
    <Fragment ref={node} {...props}>
      {createPortal(
        <StyleSheetManager target={portalRoot.shadowRoot}>
          <PortalContainer>{children}</PortalContainer>
        </StyleSheetManager>,
        portalRoot.shadowRoot
      )}
    </Fragment>
  );
};

export default Portal;
Enter fullscreen mode Exit fullscreen mode

Next, create the Portal component. Add an effect to append portalRoot to the parent element of the component. From there, pass children and portalRoot.shadowRoot to createPortal.

Remember to scope your styles to the Portal Shadow DOM layer using StyleSheetManager and reset child elements' styles to their default values.

/* @jsx h */
import { h } from "preact";
import styled, { StyleSheetManager } from "styled-components";

import Portal from "./Portal.jsx";

const Heading = styled.h3`
  color: #e155f5;
  font-family: sans-serif;
`;

const WidgetContainer = styled.div`
  all: initial;
`;

const App = () => {
  return (
    <StyleSheetManager target={document.querySelector("#app-root").shadowRoot}>
      <WidgetContainer>
        <Heading>Hey, Shadow DOM!</Heading>
        <Portal>
          <Heading>Hey, Shadow Portal!</Heading>
        </Portal>
      </WidgetContainer>
    </StyleSheetManager>
  );
};

export default App;
Enter fullscreen mode Exit fullscreen mode

Now, we can wrap any fullscreen component within our Portal.

Screen Shot 2021-10-07 at 4.09.34 PM

Conclusion

Recently, our team has released the widget to GA. The techniques outlined above have allowed us to build a rich application experience with a small codebase that is... mostly encapsulated. We still run into the occasional z-index issue or JavaScript event conflict provided by a website builder theme. Overall, widget installs have been a success.

Discussion (1)

Collapse
dannyengelman profile image
Danny Engelman

So if you write it without dependencies in plain JavaScript, everything is encapsulated.
And by the looks of it the code will probably be shorter as well.