Creating JavaScript Widgets

August 25, 2021

You may have seen our Omnichannel widget that helps customers reach out to their favorite brands on their favorite messaging platforms.

In this article, I will guide you through how to create a simple widget from tooling setup to distribution.

Before we start, here is what we are going to build:

Tooling and Technologies

There is too much tooling in the frontend world and hardly any of them will be the "wrong" tool to use, but these are my thoughts on the tools we should use for this basic widget.

Should we use a framework?

There are a lot of benefits to using a framework. For instance, you don't have to figure out rendering, state, or events. In other words, a lot of heavy lifting is done for you. However, this decision could be challenged when you are shipping a JavaScript widget instead. There are additional factors at play here, mainly the bundle size.

The additional size comes from the framework's runtime. If you decide to go with a framework, you must include this runtime code. This leads to an accumulation of wasted kilobytes down the wire, which means you will pay more for bandwidth to your hosting provider. We are talking about millions of hits possibly, so these few kilobytes will add up in the long run.

TypeScript

Secondly, are we going to use JavaScript or TypeScript?

I prefer going with TypeScript because it makes everything easier to reason with. Since you are building a public API, you need that Type-checking going on.

It also makes it possible for you to publish the typings for your widget on NPM to make it easier for your consumers to interact with your widget API.

Bundler

We will also need to choose a bundler to bundle our code into a single file. This makes it easier to deploy updates to the widget later on as we only have to update a single file. The choice is entirely up to you, whether you choose Rollup or Webpack.

We are going with Webpack in this article.

CSS Preprocessor

Lastly, what about styles? Any pre-processor you like would work fine. I will go with my minimal personal choice postcss.

Now that we've figured out the technologies we are going to use, we can write some actual code. Let's start by configuring the project.

Configuring the project

First, create a new folder and create an empty package.json file:

shmkdir greeter-widget
cd greeter-widget
npm init -y

Then start adding your dependencies:

shnpm i typescript webpack webpack-dev-server webpack-cli  \
ts-loader postcss postcss-loader css-loader style-loader \
html-webpack-plugin autoprefixer cssnano cross-env

The use of every package we added will be explained and cleared up when we start writing the configuration files. To give you an overview of what we will end up having, this is the folder structure of the project:

shgreeter-widget
  ├── dist
  ├── package-lock.json
  ├── package.json
  ├── postcss.config.js
  ├── src
  │   └── index.ts
  ├── tsconfig.json
  ├── index.html
  └── webpack.config.js

We can start with the tsconfig.json:

tsconfig.jsonjson{
  "compilerOptions": {
    "baseUrl": ".",
    "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,
    "paths": {
      "@/*": ["src"]
    }
  },
  "include": ["src/"]
}

Then to configure postcss we need to write this postcss.config.js file:

postcss.config.jsjsconst isProduction = process.env.NODE_ENV === 'production';

module.exports = {
  plugins: [
    // adds browser vendor prefixes to style properties
    require('autoprefixer'),
    // minifies the CSS if in production
    isProduction ? require('cssnano') : null,
    // removes any null elements we may have because of the conditional
  ].filter(Boolean),
};

The last configuration we need to write is for webpack.config.js which quite a bit of code:

webpack.config.jsjsconst path = require('path');
const webpack = require('webpack');
const HTMLWebpackPlugin = require('html-webpack-plugin');

const isProduction = process.env.NODE_ENV === 'production';

module.exports = {
  entry: './src/index.ts',
  mode: isProduction ? 'production' : 'development',
  output: {
    library: 'GreeterWidget',
    libraryTarget: 'umd',
    libraryExport: 'default',
    path: path.resolve(__dirname, 'dist'),
    filename: `widget${isProduction ? '.min' : ''}.js`,
  },
  resolve: {
    extensions: ['.tsx', '.ts', '.js'],
  },
  module: {
    rules: [
      {
        test: /\.ts$/,
        use: [{ loader: 'ts-loader' }],
      },
      {
        test: /\.css$/,
        use: ['css-loader', 'postcss-loader'],
      },
    ],
  },
  plugins: [
    isProduction
      ? null
      : new HTMLWebpackPlugin({
          template: path.resolve(__dirname, 'index.html'),
        }),
    isProduction ? null : new webpack.HotModuleReplacementPlugin(),
    // removes the null conditional entries
  ].filter(Boolean),
};

Then let's add a few commands to make development and testing easier for us. Add the following lines to package.json:

package.jsonjson{
  "name": "my-widget",
  "version": "1.0.0",
  "scripts": {
    "build": "cross-env NODE_ENV=production webpack",
    "dev": "webpack-dev-server --open"
  }
}

Let's add something to src/index.ts to test the setup:

src/index.tstsexport default class GreeterWidget {}

And let's add some boilerplate to index.html. This file won't be shipped to your users as we only use it to test the widget in action. Keep this in mind as it will come in handy later.

index.htmlhtml<!DOCTYPE html>
<html lang="en">
  <body></body>
</html>

Lastly, let's verify our setup is working by running:

shnpm run build

You should see a successful output log in your console, and a widget.min.js should exist in your dist folder.

Widget as Web Component

Now that we have our setup working, there are a few ways we can implement the widget. Your widget can be complex or it can be very simple. For the sake of simplicity, we will stick to the goal here by displaying a greeting widget and nothing more.

Similar to chat widgets, our widget can be only created once, and is auto mounted at the document body. The user has no control over where it should be placed.

Whichever way we implement it, our users will include a script tag and that will inject the widget code into the page. Then, they will initialize the widget by calling a function or by instantiating an object:

jswindow.GreeterWidget('Hello there!');

To quickly illustrate what our widget will have to do to get that working:

js// Widget
export default function createWidget(greeting) {
  const widgetDiv = document.createElement('div');
  widgetDiv.className = 'widget';
  widgetDiv.innerText = greeting;

  // Inject HTML
  document.body.appendChild(widgetDiv);
}

Now that you've got a feel for the first approach, I have a few problems with choosing it.

Injecting style will be tricky, and our styles may leak out to user code or the other way around. For example, the widget class could be used/changed by the consumer code. This would force us to use complex naming conventions in hopes that the user won't write conflicting styles. You may choose to take that path, but for our case we want our widget to be closed off from user code.

We can do this by building the widget as a web component that allows us to isolate the styles and gives us some nice modern API that can scale with our needs later.

Implementing the Widget

This is the actual meat of this article. We can start writing actual code now.

First we need to define the template as we normally would with normal HTML:

src/template.tstsexport function createTemplate(greeting: string) {
  const template = document.createElement('template');

  template.innerHTML = `
    <div class="widget">
      <p class="greeting">${greeting}</p>
    </div>
  `;

  return template;
}

We wrapped it in a createTemplate function to allow it to be configurable with the greeting provided by the user.

We can then use the template in the web component:

src/index.tstsimport { createTemplate } from './template';

// the widget tag name
const WC_TAG_NAME = 'greeter-widget';

// configures and defines the web component
export default function createWidget(greeting: string) {
  const template = createTemplate(greeting);

  class GreeterWidgetElement extends HTMLElement {
    constructor() {
      super();

      const shadowDOM = this.attachShadow({ mode: 'open' });
      // Render the template in the shadow dom
      shadowDOM.appendChild(template.content.cloneNode(true));
    }
  }

  if (!customElements.get(WC_TAG_NAME)) {
    customElements.define(WC_TAG_NAME, GreeterWidgetElement);
  }
}

Now let's see if it renders the message correctly. Let's add the following lines to our index.html file first to allow us to preview it:

index.htmlhtml<!DOCTYPE html>
<html lang="en">
  <body>
    <greeter-widget></greeter-widget>

    <script>
      window.onload = () => {
        window.GreeterWidget('Hello world!');
      };
    </script>
  </body>
</html>

Then start our development server by running:

shnpm run dev

Verify that the message is rendered correctly. Now let's go back to the code. We still need to style our component, so create a src/index.css file with the following content:

src/index.csscss.widget {
  position: fixed;
  bottom: 2rem;
  right: 2rem;
  background: #eac8af;
  padding: 0 40px;
  border-radius: 10px;
  border: 2px solid #d8ac9c;
  box-shadow: 4px 5px 15px 3px rgba(0, 0, 0, 0.16);
}

.greeting {
  color: #1b2021;
  font-size: 1rem;
  font-family: Arial, Helvetica, sans-serif;
  font-weight: 500;
}

What is missing is how we are going to include these styles into our widget code. The neat thing about bundlers is that they can import anything as a JavaScript module, so we can import the styles and print their content as a string:

src/template.tstsimport styles from './index.css';

export function createTemplate(greeting: string) {
  const template = document.createElement('template');

  template.innerHTML = `
    <style>${styles.toString()}</style>
    <div class="widget">
      <p class="greeting">${greeting}</p>
    </div>
  `;

  return template;
}

TypeScript will complain about importing a .css file as we need to provide definitions for it. To do that, create a src/globals.d.ts file and add the following:

src/globals.d.tstsdeclare module '*.css' {
  export default {};
}

Then re-run the development server and it should work and render our component with styles.

One last thing to address is that since our web component is a singleton, there can only be one at any given time.

We should then hide that implementation detail away and auto-mount the component without the user writing the tag for it.

src/index.tstsimport { createTemplate } from './template';

const WC_TAG_NAME = 'greeter-widget';

export default function createComponent(greeting: string) {
  const template = createTemplate(greeting);

  class GreeterWidgetElement extends HTMLElement {
    constructor() {
      super();

      const shadowDOM = this.attachShadow({ mode: 'open' });
      shadowDOM.appendChild(template.content.cloneNode(true));
    }
  }

  if (!customElements.get(WC_TAG_NAME)) {
    customElements.define(WC_TAG_NAME, GreeterWidgetElement);
  }

  // create an instance of the component
  const componentInstance = document.createElement(WC_TAG_NAME, {
    is: WC_TAG_NAME,
  });
  // mount the component instance in the body element
  const container = document.body;
  container.appendChild(componentInstance);
  // returning the instance will be useful later
  return componentInstance;
}

Now all the user has to do is just call the createComponent function and it will do all the work for them, we can remove the <greeter-widget> tag safely.

diff<!DOCTYPE html>
<html lang="en">
  <body>
-  <greeter-widget></greeter-widget>

    <script>
      window.onload = () => {
        window.GreeterWidget('Hello world!');
      };
    </script>
  </body>
</html>

Handling Updates

Our component should be rendering everything correctly now with the configured greeting message. But once it is created, the user cannot change the greeting message, which is not ideal.

Since this is a web component, we can add an attribute getter/setter:

src/index.tstsclass GreeterWidgetElement extends HTMLElement {
  constructor() {
    super();

    const shadowDOM = this.attachShadow({ mode: 'open' });
    // Render the template in the shadow dom
    shadowDOM.appendChild(template.content.cloneNode(true));
  }

  get greeting(): string {
    const greetingEl = this.shadowRoot?.querySelector('.greeting');

    return greetingEl?.textContent || '';
  }

  set greeting(val: string) {
    const greetingEl = this.shadowRoot?.querySelector('.greeting');

    if (greetingEl) {
      greetingEl.textContent = val;
    }
  }
}

The pair of getter/setter allow us to change the greeting text dynamically, which comes in handy for dynamic widgets. You can test this out by changing the text after a timeout:

html<script>
  window.onload = () => {
    widget = window.GreeterWidget('Hello world!');

    setTimeout(() => {
      widget.greeting = 'This is another message!';
    }, 3000);
  };
</script>

Verify that the widget message changes after 3 seconds, and that's it. We are all done with the implementation.

Distribution

The final step is to figure out how your users will use the widget in their web applications.

Typically, they would need to add a script tag with the hosted file URL, something like this:

html<script src="https://mydomain.com/path/to/widget.js"></script>

You can use your existing host or any commercial CDN provider like Netlify or AWS CloudFront.

There are a few best practices that you should be aware of in the next sub-sections.

Security

You should always serve the file over HTTPS, your host provider should already allow you to do so by uploading an SSL certificate or generating one for you.

Optionally, you can provide an integrity hash to the script tag to ensure your users download the actual file you serve and not some malicious file injected by an attacker.

To generate the hash for your script tag, run this command in your terminal in the root of the project:

shcat dist/widget.min.js | openssl dgst -sha384 -binary | openssl base64 -A

This should output a hash similar to this one:

shkRfSBvGoMh22K0KQ/jPLYPZTIF1BMv1egl64FRyqAZkR+H0dS31UmWminBwYrNEt

Your hash might be different because it depends on the contents of the file.

Lastly, add the integrity attribute to the script tag before instructing your users to copy it, and set its value to the hash you got in the previous step.

Don't forget to add sha384- to the start of the hash.

html<script
  src="https://mydomain.com/path/to/widget.js"
  integrity="sha384-kRfSBvGoMh22K0KQ/jPLYPZTIF1BMv1egl64FRyqAZkR+H0dS31UmWminBwYrNEt"
></script>

For more information on content security you can check this link on the MDN.

Versioning

Whenever you introduce a breaking change into the widget code, you should bump the widget version. A common way to do that is through the URL of the widget itself.

The previous URL will now look like this to support versioning:

html<script src="https://mydomain.com/path/to/{VERSION}/widget.js"></script>

<!-- Examples -->
<script src="https://mydomain.com/path/to/v1/widget.js"></script>
<script src="https://mydomain.com/path/to/v2/widget.js"></script>

Versioning like this will ensure your old users will be able to continue using the widget while offering the new widget to your newer users and those who are willing to upgrade.

We can integrate the versioning automatically into our build process. Begin by installing semver to our dependencies:

shnpm i semver --dev

Then modify the webpack.config.js:

webpack.config.jsjsconst path = require('path');
const webpack = require('webpack');
const HTMLWebpackPlugin = require('html-webpack-plugin');
const semver = require('semver');

const isProduction = process.env.NODE_ENV === 'production';

// get the current package.json version
const { version } = require('./package.json');
// extract the major (left-most) number
const { major } = semver.parse(version);

module.exports = {
  entry: './src/index.ts',
  mode: isProduction ? 'production' : 'development',
  output: {
    library: 'GreeterWidget',
    libraryTarget: 'umd',
    libraryExport: 'default',
    path: path.resolve(__dirname, 'dist', `v${major}`),
    filename: `widget${isProduction ? '.min' : ''}.js`,
  },
  // ...
};

Now the only thing you need to do is to update the version field in package.json:

package.jsonjson{
  "name": "greeter-widget",
  "version": "1.0.0",
  "scripts": {
    "build": "cross-env NODE_ENV=production webpack",
    "dev": "webpack-dev-server --open"
  },
  ...
}

Auto Loading the Script

Not all of your users are going to know how to initialize your script. Remember that for them to render the widget, they need to add the script tag and include this bit here:

html<!-- Step 1: Include the script -->
<script src="https://mydomain.com/path/to/{VERSION}/widget.js"></script>

<!-- Step 2: Create the widget -->
<script>
  window.onload = () => {
    window.GreeterWidget('Hello world!');
  };
</script>

If your users are having difficulties creating the widget programmatically, you could implement an autoloader for them that configures the widget automatically.

As an example, check our Omnichannel widget here. In a similar way, you can let users configure their widget using a human-friendly form.

The output of the form is a script that loads the widget code and then creates it for them.

The following snippet can serve as the basis for your new auto-loading script:

html<!-- Only 1 step -->
<script>
  (function (d, s, id) {
    var js,
      el = d.getElementsByTagName(s)[0];
    // avoids loading the script twice by mistake
    if (d.getElementById(id)) {
      return;
    }
    // create a script tag
    js = d.createElement(s);
    // assign an id so we can detect if we already added it
    js.id = id;
    // assign the src to the script's CDN URL
    js.src = 'https://mydomain.com/path/to/{VERSION}/widget.js';
    el.parentNode.insertBefore(js, el);
    // initializes the widget when the script is ready
    js.onload = function () {
      var w = window.GreeterWidget;
      GreeterWidget.create('Hello there!');
    };
  })(document, 'script', 'greeter-js');
</script>

All that's needed now is to instruct your users to copy their snippet and paste it into their HTML, and it should work automatically for them.

Conclusion

What we've built can be used as a good start for your app or company's widget, which can be deployed to other websites. We used a modern workflow and implemented a few best practices to ensure we don't break any user's widget.

You can find the source code here

Thanks for reading 👋