Embeddable Web Applications with Shadow DOM
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
)
}
Conclusion
We looked at how to embed a React / Preact application into another web app or site with either a CDN or through NPM. We’ve tackled injecting styles and managing fonts. You find an example of this code on my Github.