Web components in Storybook

The landscape of web development is always changing, and tools and technologies often become obsolete, many times because the problem they solve is incorporated into web standards and made part of HTML, CSS and JavaScript. Frameworks like React or Vue have enjoyed great popularity over the past years because they solve the problem of componentization, but since Web components are now established and ready to use, the day when we move on from those frameworks might be closer than we know.

Regardless of what framework we use, we want to split our interface into reusable components. Storybook is the industry standard tool for facilitating this process. It allows us to develop our components in isolation, and it gives us plenty of useful functionality that allows us to prototype and display our components. If we make the move to web components we still want to keep all of these advantages.

Web components are still a new technology and many developers are unfamiliar with them. Therefore it might not be obvious how to incorporate them into Storybook. But if we remember that web components are just HTML and JavaScript, and Storybook is really just a dynamic renderer of web pages, it turns out that it’s not so hard after all.

Plain web components vs LitElement

Before we get into it we need to make a distinction. Web components are a collection of several web standards and browser apis that are used together. When you write a web component from scratch you extend the existing JavaScript class HTMLElement (or a similar one). You then have to write code to handle lifecycle events and state and such.

Some folks in the Chrome team have written an intermediary class that takes care of the boilerplate code and more. Their group is called the Polymer project and the class you can inherit from is called LitElement. While it does output regular web components in the end, the way you write the code is quite different.

If you are like me you want to get a good grasp of how plain web components work before you move on to any abstractions or assistance. Therefore, this article only deals with plain web components.

Initial setup

Installing Storybook for use with web components can be done manually or with the aid of Storybook’s installation tools. To use the latter, run the following command in a project that already has a package. json file:

npx sb init --type html

This will set up and install all the necessary dependencies as well as create folders with config files and some example stories.

If you prefer to set up Storybook manually, see the configuration documentation for what files and folders you need to create.

Unless you have changed anything you can now run:

yarn storybook

And Storybook will be up and running locally:

An instance of Storybook running locally. Screenshot.

And you should have a list of dev-dependencies in your package.json-file that looks something like this:

"devDependencies": {
"@babel/core": "^7.12.13",
"@storybook/addon-essentials": "^6.1.17",
"@storybook/html": "^6.1.17",
"babel-loader": "^8.2.2"

You might be wondering why we use the html setup and not the one called “Web components”. This is because the web component setup is actually made to handle components written with LitElement. Since plain web components are just html, css, and JavaScript, this means that anywhere you can write regular html you can write a web component. No need to complicate things.

Registering your components

I use a very simple button component as an example. (I call it MacButton because it’s going to be part of a component library that emulates the look of an old Macintosh computer.) The component code looks like this:

const style = `
button {
position: relative;
border: 2px solid black;
padding: 4px 24px;
background-color: #fff;
color: #000;
font-family: Krungthep;

const template = ` <button><slot></slot></button>`;

class MacButton extends HTMLElement {
constructor() {
this.attachShadow({ mode: "open" });

connectedCallback() {
const { shadowRoot } = this;
shadowRoot.innerHTML = `${style} ${template}`;

customElements.define("mac-button", MacButton);

I use it in an html file like so:

<script src="”mac-button.js”"></script>

And in the browser it looks like this:

A square button in the style of old MacOS. Screenshot.

Before you can render a web component, or more specifically, a custom element, in HTML it needs to be registered in the browser. That is what the final line in the component code does.

customElements.define("mac-button", MacButton);

You can have this call here, or in a separate place, as long as it is run once before we try to use it in HTML.

In Storybook each story is rendered in a document in an iframe. It is in this document we need to have our components registered in order for them to be renderable. Luckily, Storybook has a configuration file called preview.js where we can do imports and set global decorators and more. We can also run any code we want. Like the registration code for our components! In preview.js, import the file that contains the registration code and we are good to go.

import “mac-button.js”

Write your stories

Now we are ready to write some stories. Following the example provided by the setup script, the easiest way to write a html story is as follows.

export default {
Title: “MacButton”,

export const MacButton = () => `<mac-button>MacButton</mac-button>`

Returning a string containing html will parse and render it to the document and since the custom-button element is already registered it will render without problem (I also deleted all of the other example stories).

An instance of Storybook with a single story called Button. Screenshot.

Args and addons

There are two ways to write html stories. One is returning a string, as demonstrated above, the other is to create and return an html element programmatically. The following code yields the exact same result as the example that returns a string.

export default {
Title: “MacButton”,

export const MacButton = () =>{
const macButton = document.createElement(“mac-button”);
macButton.innerHTML = “MacButton”;
Return macButton;

There is one negative aspect to this way of writing html stories: this is probably not the way that our users will use the component in the end. The closer we can get to actual use cases the better, but there are a few significant advantages to this method as well.

Speaking of event listeners, this is exactly what we will use to enable the Actions addon. We configure the addon via the argTypes, and then use it inside the template by setting the elements onClick attribute to the function we just configured.

We also configure a unique arg for the story, here called “text”. This one can be different for each story. Since the Controls addon is enabled by default, it will automatically become a controllable arg that you can change live in Storybook in the browser.

Here is the final code for the story:

export default {
title: "Button",
argTypes: { clickHandler: { action: "Clicked the button!" } },

const Template = (args) => {
const macButton = document.createElement("mac-button");

macButton.innerHTML = args.text;
macButton.onclick = args.clickHandler;

return macButton;

export const Button = Template.bind({});
Button.args = {
text: "MacButton",

We now have a functioning setup where we can render our plain web components in stories. We can use args and addons, and altering a story or a component will cause an automatic reload, just like we’re used to.

The text being changed via the controls addon and the action addon working as intended. Animated gif.

Final words

When we realize that web components are just made out of HTML, CSS, and JavaScript, and that Storybook is, in the end, just rendering web pages, the path to using them together is not so long. I hope I have demonstrated that just because we want to adapt to a changing technological landscape, that doesn’t mean that we have to give up the tooling that we already know and love!

The component library that I mentioned earlier can be found at the projects Github page. If you want to learn more about working with web components and all the surrounding tooling, consider joining me as a contributor!