Implement a UI library with Web Components
Recently in TUI Musement we introduced a design system shared by different projects. We had two main objectives: on one side we wanted to offer our users a more cohesive user experience across our platforms; on the other side we wanted to make our development flow faster and more scalable.
A design system is composed of many elements: a UI component library, a style guide (colours, fonts, etc), organisation principles, best practices, and more. While the designers started creating this complex system, the developers started focusing on how to implement the UI component library.
This is our starting point: we have three single page applications, created at different times by different people. One is built with React, while the others are built with Vue.js and Nuxt.js. In addition, we know that the design system could be used for other apps in the future, so we don’t want to be tied to a specific framework.
In this article we are going to discuss how we approached the implementation of a component library and the problems we faced, focusing on these aspects:
- the technology we chose
- how we are distributing the library
- how to use the components in Vue and React
- how to document the library with Storybook
Web Components
We thought about different solutions, but we soon convinced ourselves that the best choice is Web Components: they are framework-agnostic, web standard and are now supported by all modern browsers.
While web components are convenient, it’s not easy to write one from scratch, and it requires a lot of boilerplate. So we decided to use a library to develop our components and we picked Stencil.js: it generates standards-compliant web components and it has been designed for design systems. Moreover, it has a great build system, it’s reasonably lightweight, and it’s easy to use.
Example of a component
As a proof of concept, we created a simple component, a button. We are not going to show the implementation, but you can find it here.
Distributing and importing the library
One of the most interesting features of Stencil is the ability to generate different builds at the same time, letting the consumer decide how to import and use the components.
The first way to import the library is called distribution. It’s the easiest way for the consumer to integrate the library and it comes with two great features out of the box: lazy-loading and polyfills for legacy browsers (e.g. Internet Explorer 11). All the consumer has to do is include a tiny script and Stencil will load any component on demand. There’s no need to manually define custom elements.
The second way is the Custom Elements Bundle. In this case Stencil will generate a single, tree-shakeable, bundle, containing all the components. When using this bundle, the consumer must apply polyfills if needed and manually define every custom element.
On the other hand, by manually importing and defining the components, the consumer has more control on how the components are loaded.
Since our applications implement route-level code splitting and supports lazy-loading, we already have the ability to include only the code of the components that are actually used on the page. Therefore we decided to go with this method.
The problem with code splitting
Our idea was to import and define the custom elements in the page in which they were used.
We faced a problem though. If we are using the same component on page A and page B, and the user visits both pages, the custom element will be defined two times. Unfortunately, this is not allowed. The method CustomElementRegistry.define()
will raise an exception if the custom element has already been defined.
To solve this problem we created a little utility to check if the custom element has already been defined:
To include this utility in our build, we created a rollup configuration and added an extra step in the build phase:
Our consumer can import and define the custom element like this:
Using web components in Vue
Vue offers a good support to custom elements and integrating them in Vue is pretty straightforward.
To pass props to a custom element you can use binding like with a normal Vue component. The only thing to keep in mind is the difference between HTML attributes and DOM properties.
By default Vue uses attributes to bind props to a custom element. Since attribute values are always string, our props are converted to strings.
To pass complex data like arrays, objects or functions we can use the modifier .prop
. Using this modifier the prop is bound as a DOM property instead of an attribute.
Vue can listen to native DOM events dispatched from our custom element in the way we are used to.
Using Web Components in React
Unfortunately React’s support to custom elements isn’t as good as Vue’s. React passes all data to custom elements as HTML attributes, making it impossible to pass complex data (that is, data that is not a string or a number). Native custom events are not handled correctly.
A solution to these problems exists. We can obtain a ref
to the custom element; we then use the ref
to set properties on the element as DOM properties and to add event listeners via addEventListener
.
We created a custom hook that can be used to handle this kind of logic for any custom element:
The hook takes three arguments:
- a
ref
to the custom element - an object containing the properties
- an object containing event handlers
Here is an example of how to use it:
Setting up the Storybook
We decided to use Storybook for two main reasons:
- it makes it easier to develop components: you can view the components in isolation and test them with different properties
- it offers a great documentation, that can be shared between developers and designers
In addition, it supports many frameworks (and web components, of course) and has an ecosystem of addons that extends its functionalities.
You can install Storybook in your existing project by running:
npx sb init --type web_components
To enable HMR (Hot Module Replacement) for web components you can follow this documentation.
Development using Stencil and Storybook
Both Stencil and Storybook provide a command to start the development server. We decided to create a command to run both using concurrently:
When we run dev
, Stencil builds the library into the folder dist
. The option -s
in the storybook
command tells Storybook to load static files from thedist
folder.
We can now add our library scripts into the head of the Storybook page by creating the file .storybook/preview-head.html
To view the component we are working on we can write a story:
This how the story looks in Storybook:
Components documentation
When you write stories during development you are also creating a documentation for your components.
The addon Docs, installed with Storybook, shows a documentation of properties and events defined in our component.
As explained in the addon’s documentation, the plugin needs a json file that describes the custom elements.
import { setCustomElements } from '@storybook/web-components';
import customElements from '../custom-elements.json';
setCustomElements(customElements);
This file, custom-elements.json
, can be generated automatically.
Unfortunately, the method to generate it explained in the documentation is not updated and is no longer working.
There is an alternative, as indicated here. Stencil exposes an output target that generates the docs in the form of json data that we can modify.
We can adapt the automatically generated docs to create our custom-elements.json
like this:
Adding the docs-custom
output target in stencil.config.ts
, when we build the library with the flag --docs
, Stencil will also update custom-elements.json
.
Conclusion
We are very satisfied with our UI component library so far, and with the tools we are using to build it.
Stencil provided a good developer experience, making it easy to write standard web components, and provided great build tools to distribute them; Storybook is the perfect companion, both for development and for documentation.
It can also reduce the distance between designers and developers, thanks to its integrations. The improved collaboration is one of the best outcomes of implementing a shared component library.