Plain Vanilla Components

#

What are they?

Web Components are a set of technologies that allow us to extend the standard set of HTML elements with additional elements.

The three main technologies are:

Custom elements
A way to extend HTML so that instead of having to build all our markup out of <div>, <input>, <span> and friends, we can build with higher-level primitives.
Shadow DOM
Extending custom elements to have their own separate DOM, isolating complex behavior inside the element from the rest of the page.
HTML templates
Extending custom elements with reusable markup blocks using the <template> and <slot> tags, for quickly generating complex layouts.

Those 3 bullets tell you everything and nothing at the same time. This probably isn't the first tutorial on Web Components you've seen, and you may find them a confusing topic. However, it's not that complicated as long as you build them up step by step ...

#

A simple component

Let's start with the most basic form, a custom element that says 'hello world!':

We can use it in a page like this:

Which outputs this page:

So what's happening here?

We created a new HTML element, registered as the x-hello-world tag, and used it on the page. When we did that, we got the following DOM structure:

Explaining the code of the custom element line by line:

class HelloWorldComponent extends HTMLElement {
Every custom element is a class extending HTMLElement. In theory it's possible to extend other classes – like HTMLButtonElement to extend a <button> – but in practice this doesn't work yet in Safari.
connectedCallback() {
This method is called when our element is added to the DOM, which means the element is ready to make DOM updates. Note that it may be called multiple times when the element or one of its ancestors is moved around the DOM.
this.textContent = 'hello world!';
The this in this case refers to our element, which has the full HTMLElement API, including its ancestors Element and Node, on which we can find the textContent property, which is used to add the 'hello world!' string to the DOM.
customElements.define('x-hello-world', HelloWorldComponent);
For every web component window.customElements.define must be called once to register the custom element's class and associate it with a tag. After this line is called the custom element becomes available for use in HTML markup, and existing uses of it in already rendered markup will have their constructors called.
#

An advanced component

While the simple version above works for a quick demo, you'll probably want to do more pretty quickly:

To illustrate a way to do those things with custom elements, here's a custom element <x-avatar> that implements a simplified version of the NextUI Avatar component (React):

Some key elements that have changed:

Once rendered this avatar component will have this DOM structure:

For styling of our component we can use a separate css file:

Notice that:

An example that shows the two different sizes on a webpage:

The HTML for this example centralizes the JavaScript and CSS logic to two index files, to make it easier to scale out to more web components. This pattern, or a pattern like it, can keep things organized in a web application that is built out of dozens or hundreds of different web components.

The use of the CSS @import keyword may seem surprising as this keyword is often frowned upon for performance reasons, but in modern browsers over HTTP/2 and in particular HTTP/3 the performance penalty of this approach is not that severe.

#

Adding Children

Allowing children to be added to a web component is not hard. In fact, it is the default behavior. To see how this works, let's extend the avatar example by wrapping it with a badge:

To clarify, this is the DOM structure that is created:

The x-avatar component is identical to the previous example, but how does the x-badge work?

Some notes on what's happening here:

this.insertBefore

Care must be taken to not overwrite the children already added in the markup, for example by assigning to innerHTML. In this case the span that shows the badge is inserted before the child elements.

This also means that for custom elements that should not have children, this can be enforced by calling this.innerHTML = '' from connectedCallback().

set content(value) {
Custom element attributes can only be accessed from JavaScript through the setAttribute() and getAttribute() methods. To have a cleaner JavaScript API a setter and getter must be created for a class property that wraps the custom element's content attribute. See the index.html above for where this is called.
#

Bells and whistles

Having seen what regular web components look like, we're now ready to jump up to the final difficulty level of web components, leveraging the more advanced features of Shadow DOM and HTML templates.

This can all be brought together in this page layout example, that defines a new <x-header> component:

This is the code for the newly added <x-header> component:

There is a lot happening in header.js, so let's unpack.

const template = document.createElement('template');
The header code starts out by creating an HTML template. Templates are fragments of HTML that can be easily cloned and appended to a DOM. For complex web components that have a lot of markup, the use of a template is often more convenient. By instantiating the template outside the class, it can be reused across all instances of the <x-header> component.
<link rel="stylesheet" href="${new URL('header.css', import.meta.url)}">
Because this component uses a shadow DOM, it is isolated from the styles of the containing page and starts out unstyled. The header.css needs to be imported into the shadow DOM using the <link> tag. The special import.meta.url trick imports the CSS file from the same path as the header.js file.
<slot></slot>
The <slot> element is where the child elements will go (like the <x-badge> child of <x-header>). Putting child elements in a slot is similar to using a children prop in a React component. The use of slots is only possible in web components that have a shadow DOM.
constructor() {

This is the first example that uses a constructor. The constructor is called when the element is first created, but before it's ready for DOM interaction. The default behavior of a constructor is to call the parent class's constructor super(). So if all that is needed is the default HTMLElement constructor behavior then no constructor needs to be specified.

The reason it is specified here is because the constructor is guaranteed to be called exactly once, which makes it the ideal place to attach a shadow DOM.

if (!this.shadowRoot) { this.attachShadow({ mode: 'open' });

attachShadow attaches a shadow DOM to the current element, an isolated part of the DOM structure with CSS separated from the containing page, and optionally with the shadow content hidden away from the parent page's JavaScript context (if mode: 'closed' is set). For web components that are used in a known codebase, it is usually more convenient to use them in open mode, as is done here.

if (!this.shadowRoot) { is not strictly necessary, but allows for server-side generated HTML, by making use of declarative shadow DOM.

this.shadowRoot.append(template.content.cloneNode(true));

The shadowRoot property is the root element of the attached shadow DOM, and is rendered into the page as the <x-header> element's content. The HTML template is cloned and appended into it.

The shadow DOM becomes immediately available as soon as attachShadow is called, which is why the template can be appended in the constructor, and why the update() method can be called there. For custom elements without shadow DOM rendering the element's content should be deferred until connectedCallback().

All the new files of this example put together:

As you can see in header.css, styling the content of a shadow DOM is a bit different:

#

Passing Data

Everything up to this point assumes that data passed between web components is very simple, just simple numeric and string attributes passing down. A real world web application however passes complex data such as objects and arrays from parent to child components and vice versa.

This example demonstrates the three major ways that data can be passed between web components:

Events

The first way is passing events, usually from child components to their parent component. This is demonstrated by the form at the top of the example.

Every time the Add button is pressed a CustomEvent of type add is dispatched using the dispatchEvent method. The event data's detail property carries the submitted form data.

The event is handled one level up:

The update() method sends the updated list back down to the <santas-list> and <santas-summary> components, using the next two methods.

Properties

The second way to pass complex data is by using class properties, as exemplified by the <santas-list> component:

The list setter calls the update() method to rerender the list.

This is the recommended way to pass complex data to stateful web components.

The best practice way of implementing attributes, properties and events is subtle and opinionated. The article making web components behave closer to the platform explains how custom elements can be made to behave like built-in elements, and is recommended reading when making web components that will be embedded in third-party sites.

Methods

The third way to pass complex data is by calling a method on the web component, as exemplified by the <santas-summary> component:

This is the recommended way to pass complex data to stateless web components.

Complete example

Finally then, here is all the code for the Santa's List application:

Up next

Learn about styling Web Components in ways that are encapsulated and reusable.