Embeddable Web Applications with Shadow DOM | Viget

Embeddable Web Applications with Shadow DOM | Viget

notion image
Let's build an embeddable Preact web app using the Shadow DOM.
I recently had a fun problem to solve. We needed an embedable web app that a client could either pull from a CDN or install via NPM.
Originally, we reached for the tried and true React, but size concerns pushed us in another direction. We needed up using Preact. Preact aims to duplicate React’s functionality in a much smaller file size. If you know React, Preact is a pretty seamless adjustment.
Honestly, this embed isn’t much different from any other React application. React already hooks into a particular element that you specify in an html file. This means you can use as much or as little React as you’d like in your static site.
The biggest problem for us was that we needed to isolate the styling of our embed from the host application. Since we have no way of knowing what the host application looks like, we need want to isolate our CSS from the host CSS so that our styles don’t bleed into the host application and vice versa.
We accomplished this goal by using the Shadow DOM, which sadly has nothing to do with a trading card game I played as a kid.
Internet Explorers Beware! Shadow DOM is not supported on IE11, but is supported on most modern browsers.

Step 1 - Initial Tooling

Let’s start building! Start by initializing a new project - I prefer yarn, but you can use whatever floats your boat.
Once you’ve got your new project, you can start building your project like normal. For this example, I’m using Preact and Typescript, Styled Components for my CSS-in-JS solution, Babel for compilation, and Webpack for bundling.

Preact and Styled Components

First, run yarn init -y. This will create a mostly empty package.json. If you want to run through the whole package creation, you can leave off the -y and run through the wizard.
Next run yarn add preact styled-components to install Preact and Styled Components.
Now run yarn add --dev typescript to add TypeScript.
Finally, add "main": "dist/embed.js" to your package.json to include the appropriate main file that is the entry point into your application. When we run our build commands later, we’ll build out to this file.
Once you’ve done all of that, your package.json should look something like:
{ "name": "embedable-preact", "version": "0.0.1", "private": false, "main": "dist/embed.js", "license": "MIT", "dependencies": { "preact": "^10.6.6", "styled-components": "^5.3.3" }, "devDependencies": { "typescript": "^4.6.2" } }

Add Babel and Webpack

Babel Installation
yarn add --dev @babel/core \ @babel/plugin-transform-react-jsx \ @babel/preset-env @babel/preset-react \ @babel/preset-typescript \ babel-plugin-styled-components
This is pretty standard Babel presets. For Preact, we’ll need to alias a couple of components to get Babel working correctly. Since we’re using Styled Components, we also need the plugin for it.
Babel Configuration I usually put my babel config directly in the package.json unless it gets complicated. Feel free to use babel.config.js or another format.
"babel": { "plugins": [ [ "@babel/plugin-transform-react-jsx", { "pragma": "h", "pragmaFrag": "Fragment" } ], "babel-plugin-styled-components" ], "presets": [ "@babel/env", [ "@babel/typescript", { "jsxPragma": "h" } ] ] },
Webpack Installation
yarn add --dev webpack \ webpack-cli webpack-dev-server \ css-loader ts-loader html-webpack-plugin
Pretty straight forward here, too. We’ll need a loader for CSS and TypeScript, plus some nice-to-haves - a dev server and a plugin to help us manage changes to our host HTML.
Webpack Configuration First, we’ll spin up a webpack.config.js.
This is a little bit more involved. Since we want this to be accessible from either a CDN or NPM, we need two different build outputs in our config.
const esmOutput = { path: path.join(__dirname, "dist"), filename: "scheduler.js", library: { type: "module", }, } const umdOutput = { path: path.join(__dirname, "dist"), filename: "scheduler.js", library: "Scheduler", libraryTarget: "umd", umdNamedDefine: true, }
Our esmOutput will be used to build files for NPM while our umdOutput will build files for our CDN. Both should be fairly small once you’ve optimized your build for production.
The rest of the webpack config should be pretty familiar if you’ve used Webpack before. The big kicker here is just aliasing a couple of React things to Preact so Styled Components (and anything that has a peer dependency on React) knows where to look.
module.exports = ({ esm }) => ({ entry: path.join(__dirname, "src"), output: esm ? esmOutput : umdOutput, mode: "development", module: { rules: [ { test: /\.tsx?$/, exclude: /node_modules/, use: [ { loader: "ts-loader", options: { transpileOnly: true, }, }, ], }, { test: /\.css$/i, loader: "css-loader", }, ], }, resolve: { extensions: [".ts", ".tsx", ".js", ".html"], alias: { react: "preact/compat", "react/jsx-runtime": "preact/jsx-runtime", }, }, plugins: [ new HtmlWebpackPlugin({ template: "src/index.html", }), ], devtool: "source-map", devServer: { static: path.join(__dirname, "dist"), port: 4000, }, experiments: { outputModule: esm, }, })
There’s a couple of things to point out in here. I chose to pass esm as a flag into my Webpack config. If this is true, I want to use esmOutput, otherwise, use umdOutput.
Finally, Webpack needs experiments.outputModule to be true to actually output our module, so we set value to true when we provide the esm flag. If you reach for Rollup or another bundler for this, you wouldn’t be wrong, but I wanted to keep everything in the same bundler.
Once everything is said and done, your webpack.config.js should look something like this:
const path = require("path") const HtmlWebpackPlugin = require("html-webpack-plugin") const esmOutput = { path: path.join(__dirname, "dist"), filename: "scheduler.js", library: { type: "module", }, } const umdOutput = { path: path.join(__dirname, "dist"), filename: "scheduler.js", library: "Scheduler", libraryTarget: "umd", umdNamedDefine: true, } module.exports = ({ esm }) => ({ entry: path.join(__dirname, "src"), output: esm ? esmOutput : umdOutput, mode: "development", module: { rules: [ { test: /\.tsx?$/, exclude: /node_modules/, use: [ { loader: "ts-loader", options: { transpileOnly: true, }, }, ], }, { test: /\.css$/i, loader: "css-loader", }, ], }, resolve: { extensions: [".ts", ".tsx", ".js", ".html"], alias: { react: "preact/compat", "react/jsx-runtime": "preact/jsx-runtime", }, }, plugins: [ new HtmlWebpackPlugin({ template: "src/index.html", }), ], devtool: "source-map", devServer: { static: path.join(__dirname, "dist"), port: 4000, }, experiments: { outputModule: esm, }, })

Build Scripts

Once you’ve got Babel and Webpack set up, add the following to your package.json
"scripts": { "start": "webpack server --open", "build:umd": "webpack build", "build:esm": "webpack build --env esm" }

Step 2 - Building the Embed

You’ve gotten through the hardest part of spinning up a new project, so now let’s build something.
Create a src directory and a few files: index.html, index.tsx, index.css, and declarations.d.ts.

index.html

Below is a very basic example that you can use your for your index.html.
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <title>Host</title> <meta name="viewport" content="width=device-width,initial-scale=1" /> <style> body { margin: 0; } </style> </head> <body> <div id="embed-root"></div> <script src="./embed.js" type="text/javascript"></script> <script type="text/javascript"> Embed.init({ rootId: "embed-root", }) </script> </body> </html>
There’s nothing too fancy happening here. In the <body>, we’re adding a <div> as the root of our embed. It’s the host element to which our embed will attach. We’re then going to load our JavaScript.
Finally, we’re going to call our embed, which we named Embed in our umdOutput Webpack settings. We call a function init and pass it the id of our root element.
If your embed requires other configuration values, you’ll pass them from the host to the embed here.

index.css and declarations.d.ts

index.css is your embed’s base CSS. It can be whatever you need it to be. I’d recommend some level of CSS reset here, but your exact needs will vary. Here’s mine.
*, *:before, *:after { box-sizing: border-box; } h1, h2, h3, p, ol, ul, fieldset, legend { margin: 0; padding: 0; font-weight: normal; } button { cursor: pointer; } ul { list-style: none; }
You’ll also need a declarations.d.ts if using TypeScript. All you’ll need is the following.
declare module "*.css"

index.tsx

Now we get into the fun stuff.
We’re going to add create two functions: Embed which will hold the actual embed, and init which will render Embed.
Our Embed will be pretty simple.
import { h } from "preact" import { StyleSheetManager } from "styled-components" type EmbedProps = { shadowRoot: ShadowRoot } const Embed = ({ shadowRoot }: EmbedProps) => { return ( <StyleSheetManager target={shadowRoot as unknown as HTMLElement}> {/* Your application goes here*/} </StyleSheetManager> ) }
The application needs to be wrapped in the <StyleSheetManager> from Styled Components. This will inject the classes generated by Styled Components into our shadow root.
We also need an initializer function that will render our application. You’ll need to pass it a few parameters, depending on your use case. The only parameter required is the id of the root element, which we call rootId.
It should look something like this:
import { h, render } from "preact" import css from "./index.css" type RootProps = { rootId: string } export const init = ({ rootId }: RootProps) => { const appRoot = document.querySelector(`#${rootId}`) if (!appRoot) { console.error("App root could not be found. Check your rootId") return null } appRoot.attachShadow({ mode: "open", }) if (!appRoot.shadowRoot) { console.error("Shadow root could not be attached") return null } const styleTag = document.createElement("style") styleTag.innerHTML = css appRoot.shadowRoot.appendChild(styleTag) render(<Embed shadowRoot={appRoot.shadowRoot} />, appRoot.shadowRoot) }
Essentially, this is looking for a document with an id equal to the rootId param. If it finds that, it attaches a shadow root to it and then injects the CSS from our index.css. Then, it renders out our application in that shadow root.
Now, you should have a working embed. Try yarn start and you should see your application!

Step 3 - Usage with a CDN

Once you’ve got your app in a solid place, it’s time to test it out. Use your preferred cloud storage and CDN providers and set up a CDN for the embed.
Running yarn build:umd will create the required files for your CDN. Drop those in your bucket and hand your QA folks (and/or clients) the URL for embed JS file. In this example, we’re outputting embed.js, so your CDN link will look something like https://your.cdn.provider/:id/embed.js.
You’ve actually done all of the work for a host application. Take your index.html from Step 2 and change <script src="./embed.js" type="text/javascript"></script> to <script src="https://your.cdn.provider/:id/embed.js" type="text/javascript"></script>. Navigating to your index.html should render your embed!

Step 4 - Usage with NPM and React

Your next step is to publish the application to NPM. Run yarn build:esm and then publish your package to NPM. Once you’ve published, you can spin up a new application, install your embed, and, well, embed it in your application.
If you’re using React for your host application, you’ll need to something like this.
import { useEffect } from "react" import { init } from "your-embed" export const HostComponent = () => { useEffect(() => { init({ rootId: "embed-root", }) }, []) return <div id="embed-root"></div> }
Pretty simply, this runs our init function from Step 2 when our HomeComponent renders. We need to wait until the component renders so that the embed has something to which it can attach. Otherwise, we’ll get an error.

Step 5 - Fonts

There’s one caveat with fonts. They need to be linked in the head of our HTML, so you’re going to have to work a bit harder to get custom fonts in your application.
One strategy would be to rely on simply serif or sans-serif for your embed’s fonts, but that means the application will look different for users who have different default fonts set. That might be fine, but we can do better.
It’s likely that your client is linking to fonts in their base CSS or directly in the head of the host application. You can have them pass a fontFamily parameter into your embed, allowing you to use their fonts.
The result would look something like what follows:
index.html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <title>Host</title> <meta name="viewport" content="width=device-width,initial-scale=1" /> <link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet" /> <style> body { margin: 0; } </style> </head> <body> <div class="main"> <h1>Embedable Preact</h1> <p>On the right, you'll see an embeded Preact component.</p> <p> In this case, it's a form, but it can be whatever you need it to be. </p> </div> <div id="embed-root" class="embed"></div> <script src="./embed.js" type="text/javascript"></script> <script type="text/javascript"> Embed.init({ rootId: "embed-root", fontFamily: "'Montserrat', sans-serif;", }) </script> </body> </html>
index.tsx
import { h, render } from "preact" import { StyleSheetManager } from "styled-components" import { Form, Navigation } from "./components" import css from "./index.css" import { AppContainer } from "./parts" type EmbedProps = { shadowRoot: ShadowRoot fontFamily: string } type RootProps = { rootId: string fontFamily: string } type AppContainerProps = { fontFamily: string } const AppContainer = styled.div<AppContainerProps>` font-family: ${(p) => p.fontFamily || "sans-serif"}; ` const Embed = ({ shadowRoot, fontFamily }: EmbedProps) => { return ( <StyleSheetManager target={shadowRoot as unknown as HTMLElement}> <AppContainer fontFamily={fontFamily}> {/* the rest of your app */} </AppContainer> </StyleSheetManager> ) } export const init = ({ rootId, fontFamily }: RootProps) => { const appRoot = document.querySelector(`#${rootId}`) if (!appRoot) { console.error("App root could not be found. Check your rootId") return null } appRoot.attachShadow({ mode: "open", }) if (!appRoot.shadowRoot) { console.error("Shadow root could not be attached") return null } const styleTag = document.createElement("style") styleTag.innerHTML = css appRoot.shadowRoot.appendChild(styleTag) render( <Embed shadowRoot={appRoot.shadowRoot} fontFamily={fontFamily} />, appRoot.shadowRoot ) }