Sometimes, along with the main product, there is the need to create embeddable lightweight widgets, that your b2b customers can add to their website to show notifications, ads, quick chat functionality, etc. In this blog post, I will show how I implemented it for our product. You will have a ready-to-insert script that will download and run the bundle file. The main requirement for our widget is the minimum number of technologies to use. I chose web components and a Shadow DOM for this project. What is Shadow DOM? To cut a long story short, I would say that the Shadow DOM is a DOM with elements and styles that have their own scope. It is not affected by the main CSS of the website because it’s encapsulated. You can’t access the Shadow DOM elements with querySelector or other APIs and you don’t need to think about CSS collisions. Fantastic no? You might think iframes are the most popular form of scoped DOMs and it’s better to use them, but iframes have their own disadvantages, such as cors, and responsiveness. Shadow DOM pros Shadow DOM cons Scoped DOM Compatibility Scoped CSS Using multiple Shadow DOM projects can cause conflict with each other Composition Accessibility Performance Custom fonts 💡 Just FYI, I’m going to include code snippets with explanations for each step, so that we can “code along” and figure it out bit by bit. Also at the top of each snippet, you will find the name of the file, so you’re not confused about where to put it. But be careful not to copy it with the code, as it will cause an error for some extensions. Setting up our project Let’s start building our project by creating a folder called ”whatever name you like”. Please open a shell and execute . Then install our first dependencies. I hope you are already familiar with and know its purpose. Along with it, we're going to install Typescript for type checking and reducing the number of errors. npm init webpack npm install --save-dev webpack webpack-cli webpack-dev-server typescript ts-loader html-webpack-plugin cross-env If everything is fine, let’s create a file in the root of our directory with the name and the next config inside. It's just simple instruction, which explains to Typescript how to read our code. Nothing advanced. You can read about each line . tsconfig.json here // tsconfig.json { "compilerOptions": { "baseUrl": "src", "rootDir": ".", "moduleResolution": "node", "target": "es2015", "module": "esnext", "lib": ["esnext", "es2017", "ES2015", "dom"], "sourceMap": true, "declaration": false, "declarationMap": false, "noImplicitAny": true, "strict": true, "strictNullChecks": true, "strictBindCallApply": true, "strictFunctionTypes": true, "typeRoots": ["node_modules/@types"], "esModuleInterop": true, "allowSyntheticDefaultImports": true, }, "include": ["src/**/*"], } The next important part of our setup is configuring to compile all our stuff. Please create the file called in the root of the project directory. It comes with simple instructions for bundlers to transform our code into plain JS, CSS, etc. webpack webpack.config.js // webpack.config.js const path = require('path'); const webpack = require('webpack'); const HTMLWebpackPlugin = require('html-webpack-plugin'); module.exports = { entry: { Widget: './src/index.ts', }, mode: 'development', output: { library: 'Widget', libraryTarget: 'umd', libraryExport: 'default', path: path.resolve(__dirname, 'dist'), filename: `widget.js`, }, resolve: { extensions: ['.ts', '.js', '.scss'], }, module: { rules: [ { test: /\.ts$/, use: [{ loader: 'ts-loader' }], }, ], }, plugins: [ new HTMLWebpackPlugin({ template: path.resolve(__dirname, 'index.html') }), new webpack.HotModuleReplacementPlugin(), ], }; To end up our setup - create basic and files in the root as previous files and put the next code inside to check if everything works properly. index.html index.ts // src/index.ts console.log(1); // index.html <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Widget</title> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"> </head> <body> <p>Hello world</p> </body> </html> If you see the result of in the browser, it appears that everything is functioning properly. console.log The setup is finished. Congrats, finally we can start our development. Development step We can create custom HTML elements, described by our class, with their own methods and properties, events, and so on. There are two kinds of custom elements: – “all-new” elements, extending the abstract class. Autonomous custom elements HTMLElement – extending built-in elements, like a customized button, based on etc. Customized built-in elements HTMLButtonElement In this tutorial, we need autonomous elements. To create a custom element, we need to tell the browser several details about it: how to show it, what to do when the element is added or removed from the page, etc. This is done by creating a class with special methods, which is easy to do, as there are only a few methods, and all of them are optional. A browser calls the method when the element is added to the document. At that moment we create the element and put our markup inside with property. A built-in element serves as storage for HTML markup templates. The browser ignores its contents and only checks for syntax validity, but we can access and use it in JavaScript to create other elements. is a document fragment that is attached to the main element. connectedCallback template innerHTML template this.shadowDocument To create a Shadow DOM for an element, we call . The next snippet gives you more understanding of what element we attach to the . this.attachShadow() template.content // WidgetElement.ts export const WidgetElement = () => { class WidgetElement extends HTMLElement { constructor() { super(); this.shadowDocument = this.attachShadow({ mode: 'open' }); } private shadowDocument: ShadowRoot; connectedCallback(): void { const template = document.createElement('template'); template.innerHTML = '<p>Hello world</p>'; this.shadowDocument?.appendChild(template.content.cloneNode(true)); } } return WidgetElement; }; Please remove from the file and simply copy and paste the code below. Our next step is defining the custom web component and its custom element. Let it be . We create an element that is going to be our root and attach WidgetElement ( content) to it. console.log index.ts <custom-widget></custom-widget> custom-widget template Below, we invoke our code and show the new tag by appending it to the end of the body of the website. // index.ts import { WidgetElement } from './WidgetElement'; const App = (() => { const scriptEl = document.getElementById('top-widget'); if (scriptEl) { if (!customElements.get('custom-widget')) { customElements.define('custom-widget', WidgetElement()); } const componentInstance = document.createElement('custom-widget', { is: 'custom-widget', }); const container = document.body; container.appendChild(componentInstance); } })(); export default App; Simply put the script below to instead of the tag index.html <p></p> // index.html <script id="top-widget"></script> The result will be the same, see the picture below, but using the custom web component created with the Shadow DOM. We’ve mentioned one of the Shadow DOM advantages before - Scoped CSS. I spent a fair amount of time to decide the best way to style web components and realized that there were none. As always, it depends on your experience, knowledge, stack, etc. All we really need is to find a way to get CSS and deliver inside tags at the top of our root. style I'm going to show my option, which in my personal opinion isn't bad at all. Please install the next dependencies and let's apply them one by one. npm install --save-dev css-loader node-sass sass-loader sass-to-string Reading and transforming Sass to CSS, requires adding the following loaders. is required to take styles and use them as a plain string. You will see how Sass will be imported and inserted into the template. webpack sass-to-string // webpack.config.js // add those options under ts-loader config { test: /\.scss$/, exclude: /node_modules/, use: [ 'sass-to-string', { loader: 'sass-loader', options: { sassOptions: { outputStyle: 'compressed', }, }, }, ], }, { test: /\.css$/, use: ['css-loader'], }, Now we’re creating a new directory called with file inside. Just copy and paste the example inside. You'll easily change styles later, or forget, play with them whichever way you want. styles main.scss // styles/main.scss .widget { width: 240px; height: 80px; padding: 8px; border: 1px solid #ddd; border-radius: 4px; p { margin: 0; color: gray; } } To prevent the mixing of logic in one file, let’s create a new one with the name and add a new markup with predefined CSS classes to it. Previously we explained what to do with Sass in our project, so just import the example code at the top of the file. Don't forget to add the new declaration to the new file in the root of directory for files. widget.template.ts webpack main.scss globals.d.ts src .scss // widget.template.ts import styles from './styles/main.scss'; const renderTemplate = () => { return ` <style>${styles}</style> <div class="widget"> <p>Hello world</p> </div> `; }; export { renderTemplate }; // globals.d.ts declare module '*.scss' { export default {}; } At the end of this part, we import the template and substitute an old plain string tag with it. p // WidgetElement.ts // add import at the top of the file import { renderTemplate } from './widget.template'; // update existing method private appendShadowDOMWithData = (): void => { const template = document.createElement('template'); template.innerHTML = renderTemplate(); this.shadowDocument?.appendChild(template.content.cloneNode(true)); }; During this step, we already have an idea about the style of our future widget. Let’s add some icons inside our component. It’s hard to imagine any widget without awesome icons or pictures. I suggest adding some to our widget as well, just in case you will be struggling with it. I found a simple star SVG image. Put your valid SVG icon into a new folder and import it inside our file. Don't forget to tell to recognize it and add new instructions to . assets/[name-of-your-svg].svg widget.template.ts webpack globals.d.ts // webpack.config.js // add this option under css-loader config { test: /\.(png|svg|jpg|jpeg|gif)$/i, type: 'asset/resource', }, // widget.template.ts import star from './assets/star.svg'; // and below <div class="widget"> <img src="${star}" alt="star" /> <p>Hello world</p> </div> // globals.d.ts declare module '*.svg' { export default {}; } The next step is changing our font. To be honest, I didn’t find any information on how to add fonts inside of the Shadow DOM project and apply them. But there is still access to Google fonts. I created a script that allows us to download the necessary font. We use Rubik as the main font in our product, so let’s take it here as well. // setupFonts.ts const FONT_FACE = `@import url('https://fonts.googleapis.com/css2?family=Rubik:wght@400;500&display=swap');`; export const setupFontFaces = () => { if (document.querySelector('style[data-description="font-faces"]')) { return; } const style = document.createElement('style'); style.dataset.description = 'font-faces'; style.appendChild(document.createTextNode(FONT_FACE)); document.head.appendChild(style); } In the snippet above we create element in the outer scope and import google Fonts via the link. Nothing special, I tested it several times and it didn't make any collisions with the main website. The next step is to call our function during the initialization of the widget. style setupFontFaces // WidgetElement.ts import { setupFontFaces } from './setupFonts'; // and add inside our constructor constructor() { super(); setupFontFaces(); this.shadowDocument = this.attachShadow({ mode: 'open' }); } If you open the network tab in the browser web tools, you’ll see that web font was downloaded. Rubik And here we are! Just add it either globally or to a specific selector. // styles/main.scss body { font-family: 'Rubik'; } p { margin: 0; font-family: 'Rubik'; color: gray; } All that is left is to add and position styles to our element to make it look like a real widget. The last and main part is building our project. Run and look inside the folder. There is a file . Below there is an example of how another website can access your file. Just put it somewhere on the bucket or server and serve it under some URL. position: absolute; npm run build dist Widget.min.js <script id="top-widget" src="YOUR_BUCKET_URL/Widget.min.js" ></script> Great, we have finished this simple but cool project. Just imagine what interesting embeddable widgets you can build with the help of this example.