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);
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);
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.
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);
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.
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;
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.
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;
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.
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.
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);
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;
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;
Now, we can wrap any fullscreen component within our Portal
.
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.
Top comments (1)
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.