Imagine you're building a castle out of LEGO bricks. You have a big, old box of classic bricks in different shapes and colors. Making the basic parts of the castle goes quite well: You build four towers, a gate, and walls between them. You add some nice details like battlements on top of the walls and staircases in the back.
For the towers, you want to add a special feature: A cone-shaped roof! But your box does not have any special round bricks, only the classic boxy shapes. You could make something close to a cone shape with these, but wouldn't it be nice if you could just make your own bricks from scratch? You ask a friend, and they say: "This isn't so hard, really. Just get a 3D printer and print the shape you want." Ah, so you follow their advice and spend some time modelling and printing the perfect roofs for your castle. You end up with just the castle you want, and if you need conic roofs in the future, you can just print more!
Web Components solve a similar problem in the world of web technology. To build a website or web application, you only have a fixed set of elements (div, input, img, etc.) to choose from (your box of LEGOs). While you can use as many of those as you want to and compose and combine them freely, the set is fixed. Or is it? In this lesson, we will learn how to print our own LEGOs and extend the set, just as with the 3D printer from the story. After completing this lesson, you will be able to define your own HTML elements that can be used in any website or web application without limitations.
What Web Components Are
Web Components is a suite of different technologies allowing you to create reusable custom elements — with their functionality encapsulated away from the rest of your code — and utilize them in your web apps.
As developers, we all know that reusing code as much as possible is a good idea. This has traditionally not been so easy for custom markup structures — think of the complex HTML (and associated style and script) that might be neccessary to render custom UI controls, and how using them multiple times can turn your page into a mess if you are not careful.
Historically, this problem has been solved with third-party libraries. The library that first popularized the "component" approach for the web is React. React uses a custom syntax called JSX to define functional templates for components. Components may also have reactive properties, called "props". When a prop changes, the component renders again. This enables developers to describe their user interface in a functional, declarative way: A component maps data to a HTML template, and reacts to changes in the data. This approach proved so popular that it has been adopted by every major view library afterwards, including Angular, Vue, and Svelte.
Web Components standardizes the approach of these libraries, making them available in every browser without installing anything — it consists of three main technologies, which can be used together to create versatile custom elements with encapsulated functionality that can be reused wherever you like without fear of code collisions.
HTML templates: The <template> and <slot> elements enable you to write markup templates that are not displayed in the rendered page. These can then be reused multiple times as the basis of a custom element's structure.
Shadow DOM: A set of JavaScript APIs for attaching an encapsulated "shadow" DOM tree to an element — which is rendered separately from the main document DOM — and controlling associated functionality. In this way, you can keep an element's features private, so they can be scripted and styled without the fear of collision with other parts of the document.
Custom elements: A set of JavaScript APIs that allow you to define custom elements and their behavior, which can then be used as desired in your user interface.
HTML Templates
Let us first look at templates.
When you have to reuse the same markup structures repeatedly on a web page, it makes sense to use some kind of a template rather than repeating the same structure over and over again. This was possible before, but it is made a lot easier by the HTML <template> element. This element and its contents are not rendered in the DOM, but it can still be referenced using JavaScript.
A quick example:
Templates may also contain slots, where other HTML content can be inserted. This is mostly used with custom elements, which we will look at later.
An important aspect of custom elements is encapsulation, because a custom element, by definition, is a piece of reusable functionality: it might be dropped into any web page and be expected to work. So it's important that code running in the page should not be able to accidentally break a custom element by modifying its internal implementation. Shadow DOM enables you to attach a DOM tree to an element, and have the internals of this tree hidden from JavaScript and CSS running in the page.
Shadow DOM allows hidden DOM trees to be attached to elements in the regular DOM tree — this shadow DOM tree starts with a shadow root, underneath which you can attach any element, in the same way as the normal DOM.
There are some bits of shadow DOM terminology to be aware of:
Shadow host: The regular DOM node that the shadow DOM is attached to.
Shadow tree: The DOM tree inside the shadow DOM.
Shadow boundary: the place where the shadow DOM ends, and the regular DOM begins.
Shadow root: The root node of the shadow tree.
An example:
You can affect the nodes in the shadow DOM in exactly the same way as non-shadow nodes — for example appending children or setting attributes, styling individual nodes using element.style.foo, or adding style to the entire shadow DOM tree inside a <style> element. The difference is that none of the code inside a shadow DOM can affect anything outside it, allowing for handy encapsulation.
Before shadow DOM was made available to web developers, browsers were already using it to encapsulate the inner structure of an element. Think for example of a <video> element, with the default browser controls exposed. All you see in the DOM is the <video> element, but it contains a series of buttons and other controls inside its shadow DOM. The shadow DOM spec enables you to manipulate the shadow DOM of your own custom elements.
Custom Elements
A custom element is implemented as a class which extends HTMLElement.
Let us look at an example together. Imagine we want to implement an element <my-alert> that gives some kind of message to users. Here's a minimal version of that:
The example above has three sections:
The <template> uses the HTML Templates API to define a simple alert template. It contains a <span> with some text and a <style> element. Since we will put the contents of this template in the Shadow DOM, the selector in the <style> element is very simple: There is only one span to select!
The <script> uses the Custom Elements API to create a custom element class. In the constructor, we first create a shadow root with attachShadow, then get the template defined above, and finally insert a copy of the template's content into the shadow root. Additionally, we register the custom element using customElements.define. The name must start with a lowercase letter, contain a hyphen, and satisfy certain other rules listed in the specification's definition of a valid name.
Finally, we use the element in the page, just like any other element.
Dealing with attributes
For now, our example is quite limited. Let us add an attribute to allow for some customization. We want to add a variant attribute: If it is not present, show the alert as-is. If it is "success" or "danger", give the alert a green or red background, respectively.
As we can see above, we achieve this only by changing the style in the template. We combine two selectors to achieve this: The :host() pseudoselector allows us to target the element hosting the shadow root, in this case my-alert. The attribute selector ([name=value]) targets only elements that have an attribute with the given name and value. Together, we can conditionally apply a background color only when the variant attribute is set to "success" or "danger".
Using slots
This is better, but there is another issue. Only having the text "Alert!!!" inside the alert is very limiting. What if we could any text, or even better, any HTML? For this, we use a feature of HTML templates we haven't introduced yet, slots:
A slot defines a place in the template where external content can be placed. If a custom element has a shadow DOM containing a slot, children of that custom element are slotted in there.
Adding interactivity (with events)
Say we also want to add a "close" button to our alert. This is also possible with Web Components:
To accomplish this effect, we modify the custom element MyAlert with a click event listener. Each time MyAlert is clicked, we detect if the "close" button was the element actually clicked. If so, we remove the MyAlert element from the DOM.
Custom element lifecycle callbacks
We will briefly look at another part of custom elements: Once your custom element is registered, the browser will call certain methods of your class when code in the page interacts with your custom element in certain ways. By providing an implementation of these methods, which the specification calls lifecycle callbacks, you can run code in response to these events.
Custom element lifecycle callbacks include:
connectedCallback(): Called each time the element is
added to the document.
disconnectedCallback(): Called each time the element is removed from the document.
adoptedCallback(): Called each time the element is moved to a new document.
attributeChangedCallback(): Called when attributes are changed, added, removed, or replaced.
Web Components, but with less boilerplate
In programming, there is the expression of "boilerplate code". This refers, usually negatively, to certain lines of code that always need to be added regardless of what the program is trying to achieve. As we can see in the examples above, Web Components have a lot of boilerplate! This is the case because as we learned, they are made out of three different standards which give quite low-level control over the rendering process. For example, you could attach a shadow DOM not only to a custom element, but to any element on the page. If we view it like that, a "web component" is really just a pattern of applying the standards together.
To get rid of the boilerplate code and just apply the pattern consistently, we can use a library. There are many libraries made for web components specifically (Slim, Hybrids, Stencil, Solid, ...), and also more classic view libraries that can output web components (Svelte, Vue, React). In this section, we will take a look at one of the most minimal libraries available: Lit.
Let's try Lit. Here is our exact component from above, written in Lit:
As we can see, Lit does not really do anything magical. It just makes our code a bit shorter and easier to read, as the whole logic of the component now lives in the class itself. Lit's templates also have some useful properties compared to the HTML templates we got to know earlier: We can register event listeners directly on an element with the @ syntax. This allows us to write @click=${() => this.remove()} on the button for the closing functionality instead of the more complicated event handling we did earlier. In this syntax, this is the same as writing button.addEventListener("click", () => this.remove()). Templates also have other useful features we won't elaborate on here.
Reactive properties
At the start, we heard about the library React and the feature it is named after: Reactivity, the idea that if a prop changes, the component re-renders. Lit also enables this feature, which is something not supported by the Web Components standards, but very useful.
With Lit, you can define reactive properties that cause the component to update when changed. Additionally, they can be synchronized with an attribute of the same name.
This sentence might be confusing: Aren't attributes and properties the same? No, properties exist on an JS object, while attributes are a part of DOM elements and correspond to HTML attributes. For example, in <img id="hero" title="forest" />, title is an attribute. If you access the element through the DOM API, you can get the value of the attribute: document.getElementById("hero").getAttribute("title"). The same element also has properties: document.getElementById("hero").tagName gives the name of the element. The confusion between both arises easily because the DOM has a convenience feature: Every element has a property for each standard attribute that is synchronized with the attribute. For example, document.getElementById("hero").title = "trees" would change both the property and the attribute.
Lit allows us to do the same with our own properties and attributes:
In this lesson, we about a set of three standards (HTML Templates, Shadow DOM, and Custom Elements) that enables developers to create their own HTML elements. HTML Templates enable developers to add HTML markup to the page that is not immediately rendered but stored for later. The Shadow DOM API allows adding an extra, hidden DOM tree to any element. Finally, the Custom Element API gives us access to all the functionality that native elements have: We can register new elements to the page and also react to lifecycle events or attribute changes.
We combined all these standard to apply the pattern of web components: Encapsulated parts of a web application's UI with their own styles and functionality. With HTML Templates and the Shadow DOM, components can have their own, encapsulated display that is hidden from the outside. With Custom Elements, we can have our web component behave like any other HTML element, using the lifecycle and adding it to the page.
We also learned about Lit as an example of a library to make web components easier to write, without boilerplate.
Exercises
The Components of ExplorableJS
As you probably realized, this course is just a website, as well. Now for the interesting part: There are web components in this very lesson (and every other lesson) of ExplorableJS! Answer these questions:
What's the tag name of the code cell element used for the examples?
How is the content of the code cell stored?
Can you enable any hidden features of the code cell?
Hint
You can use your browser's developer tools to take a look at the DOM and find the element (CTRL/CMD+Shift+I on Chrome/Edge/Firefox or CMD+Option+I on Safari).
For hidden features, try playing with the attributes of a code cell!
Counter Component
A classic exercise for many UI libraries is building a counter. Our counter should have the following features: It should be a custom element named my-counter, implemented in Lit. It should have a a property value storing the current count. It should show the current count and buttons to increment or decrement the counter. Additonally, it should show a label after the count.
Could you improve this counter further? Some ideas to try:
Often, it doesn't make sense for counts to be negative. Add a min attribute to control this, so that <my-counter min=0></my-counter> means that the count can't be negative.
If you haven't already, add some styling! For example, the label could be italic, and the number could be bold.
Hint
Take another look at the Lit example above if you don't know how to achieve something. You can also look up everything in Lit's documentation.