Dec 16

Web Components

Learn how to build custom HTML elements that manage their own lifecycle, state and behaviors, both with and without shadow DOM.

By Mayank

"Web components" is an umbrella term for a set of modern web APIs that make it easier to work with the DOM. Broadly speaking, it includes three main features: 

In this post, we'll use web components to build a theme toggle that will allow us to switch between regular and festive themes. When the festive theme is active, it will start snowing, which is quite similar to this very site. 

We'll go through the important bits of HTML, CSS, and JavaScript in order. A complete CodePen demo can be found at the end. 

Custom elements 

This is a custom element: 

<theme-toggle></theme-toggle>

Really, that's it. Any element whose name contains a hyphen (-) is a custom element. The naming restriction helps prevent conflicts with "built-in" elements. 

So far, this is not that different from a <span>, but it has some useful capabilities, as we'll see later. 

Start with the markup 

Let's add all our important markup inside this custom element. For our theme toggle, we just need a button with some text. We can wrap the text in a <span> to make it easier to style later.

<theme-toggle> 
<button type="button">
<span>Festive theme</span>
</button>
</theme-toggle>

To make our toggle more accessible, we'll implement the switch pattern, which requires the role="switch" and aria-checked attributes. When a screen-reader user accesses our button, they'll hear something like "Festive theme, switch, off (or on)". 

These attributes could be added here in the initial markup, but since these don't affect initial paint, and this pattern requires JavaScript to work anyway, let's save them for later. 

Style like you normally would 

We can reference custom elements in CSS selectors, just like we'd do for built-in HTML elements. Nothing new here (except the use of native CSS nesting). 

theme-toggle {
/*...*/
button {
/*...*/
span {
/*...*/
}
}
}

One important thing to mention, though, is display. By default, custom elements are inline elements, but we usually want something like inline-block or block , so that it can be sized predictably. This is also a particularly good use case for display: contents, since we care more about the button inside it. 

theme-toggle { 
display: contents;
}

Draw the rest of the owl

theme-toggle CSS
/* "-base" is temporary for this step */
theme-toggle-base {
display: contents;

& button {
all: unset;
display: grid;
grid-template-areas: "label" "toggle";
place-items: center;
gap: 4px;
cursor: pointer;

&:hover,
&:focus-visible
{
&::before {
background-color: hsl(0 0% 50% / 0.2);
}
}

&:focus-visible {
outline: 3px solid CanvasText;
outline-offset: 2px;
}

& span {
grid-area: label;
font-size: 1.5rem;
}

&::before,
&::after
{
grid-area: toggle;
content: "";
display: inline-block;
border-radius: 9e9px;
border: 3px solid;
}

&::before {
block-size: 70px;
inline-size: 120px;
transform: background-color 0.2s;
}

&:after {
block-size: 50px;
aspect-ratio: 1;
background: CanvasText;
transform: translate(-50%);
transition: transform 0.2s;
}

&[aria-checked="true"] {
&::after {
content: "";
transform: translate(50%);
}
& span::after {
content: "!!";
position: absolute;
}
}
}
}

@layer {
.theme-toggle *,
.theme-toggle ::before,
.theme-toggle ::after
{
margin: 0;
box-sizing: border-box;
}
.theme-toggle {
color-scheme: dark;
background-color: Canvas;
color: CanvasText;
font-family: system-ui;
display: grid;
place-items: center;
min-height: 30dvh;
}
}

The CSS is quite simple. Here are some of the interesting points: 

Let's enhance 

We are now ready to "enhance" our custom element with JavaScript behavior. 

A custom element must be written as a JavaScript class which extends the HTMLElement interface. 

class ThemeToggle extends HTMLElement {}

And then, it can be associated with a tag name using the customElements.define() method (available globally under window ).

customElements.define('theme-toggle', ThemeToggle);

This makes sense! Do you know how when we create or query for a built-in element like <button>, we return an instance of HTMLButtonElement? Similarly, when we query for <theme-toggle>, we'll get back an instance of ThemeToggle.

What is this? 

Inside a JavaScript class, we expect the this keyword to refer to an instance of the class. this is how we're able to access the instance fields and methods defined in our class. And since we're extending the HTMLElement interface, this also gives us access to all the regular DOM methods, like querySelector and innerHTML

There are many ways this can go wrong, especially when dealing with event listeners. Instead of doing a deep dive on this, I will suggest a simple rule that helps avoid most of the confusion: only pass around arrow functions. The arrow function can contain the entire logic or simply call an instance method. 

// ❌
something.addEventListener('click', this.handleClick);
// ✅
something.addEventListener('click', () => this.handleClick());

connectedCallback all the things 

When defining our class, we can use a bunch of handy lifecycle methods that will always be called in a predictable fashion. The most important one is connectedCallback, which gets called when the custom element is added to the document. We can set up most of our component logic here. 

First, grab our button and add the required attributes for the switch pattern we discussed earlier. 

class ThemeToggle extends HTMLElement {
connectedCallback() {
const button = this.querySelector('button');
button.setAttribute('role', 'switch');
button.setAttribute('aria-checked', 'false');
}
}

Now, let's add a "click" event handler to the button. The main thing we'll do is toggle the aria-checked attribute's value. Our CSS already knows how to react to this attribute, so we don't need to do anything else to make the toggle work. We'll also add a snowfall effect shortly, but for now, let's just store its state in an instance field this.snowing.

class ThemeToggle extends HTMLElement {
connectedCallback() {
// ...
button.addEventListener('click', () => {
this.snowing = !this.snowing;
button.setAttribute('aria-checked', this.snowing);
});
}

And just like that, our toggle switch works!

Our deliberate choice to use a <button> element as the base means that it will be automatically keyboard-focusable and the click handler will also be automatically called when pressing the Enter or Spacebar keys.

theme-toggle connectedCallback
theme-toggle {
display: contents;

& button {
all: unset;
display: grid;
grid-template-areas: "label" "toggle";
place-items: center;
gap: 4px;
cursor: pointer;

&:hover,
&:focus-visible
{
&::before {
background-color: hsl(0 0% 50% / 0.2);
}
}

&:focus-visible {
outline: 3px solid CanvasText;
outline-offset: 2px;
}

& span {
grid-area: label;
font-size: 1.5rem;
}

&::before,
&::after
{
grid-area: toggle;
content: "";
display: inline-block;
border-radius: 9e9px;
border: 3px solid;
}

&::before {
block-size: 70px;
inline-size: 120px;
transform: background-color 0.2s;
}

&:after {
block-size: 50px;
aspect-ratio: 1;
background: CanvasText;
transform: translate(-50%);
transition: transform 0.2s;
}

&[aria-checked="true"] {
&::after {
content: "";
transform: translate(50%);
}
& span::after {
content: "!!";
position: absolute;
}
}
}
}

@layer {
.theme-toggle *,
.theme-toggle ::before,
.theme-toggle ::after
{
margin: 0;
box-sizing: border-box;
}
.theme-toggle {
color-scheme: dark;
background-color: Canvas;
color: CanvasText;
font-family: system-ui;
display: grid;
place-items: center;
min-height: 30dvh;
}
}
class ThemeToggle extends HTMLElement {
connectedCallback() {
const button = this.querySelector("button");
button.setAttribute("role", "switch");
button.setAttribute("aria-checked", "false");

button.addEventListener(
"click",
() => {
this.snowing = !this.snowing;
button.setAttribute("aria-checked", this.snowing);
}
);
}
}

customElements.define("theme-toggle", ThemeToggle);

How cool is that?! Our custom element can manage its own lifecycle independently. No need to mess with DOM ready events (which are unreliable) or mutation observers (which are tedious).

Cleanup 

The disconnectedCallback lifecycle method gets called when the custom element is removed from the document. It is good practice to remove event listeners (and do other "cleanup") inside this method, especially when building for highly dynamic websites. 

The best way to remove event listeners is using an AbortController. This is done in three steps: 

  1. Store the controller in an instance field. 
  2. Pass its abort signal when adding event listeners. The same signal can be reused for multiple event listeners. 
  3. In disconnectedCallback, call abort() to remove all event listeners together. There is no need to hold on to references to every individual event listener. 
class ThemeToggle extends HTMLElement {
connectedCallback() {
// …
const { signal } = this.controller = new AbortController();
button.addEventListener(
'click',
() => { /*…*/ },
{ signal }
);
}
disconnectedCallback() {
this.controller.abort();
}
}

Shadow DOM 

Shadow DOM is another web component API that is used for attaching a "hidden" DOM tree to an element. This tree is encapsulated from the rest of the document, which means its styles and IDs won't "leak" out and vice-versa. 

One interesting thing to note: a shadow DOM tree does not always need to be attached to custom elements; it can also be attached to many built-in elements (see full list). 

With that in mind, let's build our snowfall effect. Since this effect will likely require a bunch of otherwise meaningless elements, we can use shadow DOM to avoid cluttering our main HTML. Shadow DOM is particularly good at hiding such purely presentational elements. 

Let's attach a shadow tree to the <body> element using the attachShadow method

class ThemeToggle extends HTMLElement {
// …
connectedCallback() {
// …
this.setupSnowfall();
}
setupSnowfall() {
if (!document.body.shadowRoot) {
document.body.attachShadow({ mode: "open" });
}
}
}

Some interesting notes: 

Slots 

By default, shadow trees will "replace" all the content of the element that it's attached to. We definitely want to keep our <body> content around. We can do that using a "slot". 

Slots are an important tool that provides a way to mix and match parts of shadow DOM and "light" DOM. As a general pattern, we can use slots to keep our semantically important parts in the light DOM and hide implementation details in shadow DOM. 

Let's add a <slot> element into our shadow tree. All the <body> content will be automatically filled into this slot.

class ThemeToggle extends HTMLElement { 
// …
setupSnowfall() {
// …
const slot = document.createElement('slot');
document.body.shadowRoot.appendChild(slot);
}
}

Make it snow 

Now for the fun part: the snowfall effect! There are probably better ways to do this, but I will show off a simple SVG. 

I won't show the full code because, as far as our web component is concerned, all we're doing is inserting HTML into the shadow tree. You'll find the full CodePen below. 

class ThemeToggle extends HTMLElement {
// …
setupSnowfall() {
// …
const { innerWidth: w, innerHeight: h } = window;
slot.insertAdjacentHTML('beforebegin', `\
<style>
svg { position: fixed; inset: 0; }
</style>
<svg viewBox="0 0
${w} ${h}" aria-hidden="true">
<circle>…</circle>
<circle>…</circle>
<circle>…</circle>

</svg>
`
);
}
}

Lastly, all we need to do is toggle the snow when the button is clicked. I like to use a combination of setters and private properties for this sort of logic. Our click handler already toggles this.snowing so let's just convert that into a setter. We'll execute the logic of starting/stopping the animation whenever the setter function is called.

class ThemeToggle extends HTMLElement { 
// …
#snowing;
get snowing() {
return !!this.#snowing;
}
set snowing(snowing) {
this.#snowing = snowing;
const svg = document.body.shadowRoot.querySelector('svg');
[ ... svg.querySelectorAll('animate')].forEach(a => {
if (snowing) {
a.beginElement();
} else {
a.endElement();
}
})
}
}

And we're done!

See the CodePen.

Go further

While we've covered most of the important bits of web components, there's a lot more to it! 

Mayank

Mayank

I'm a design engineer who loves all things CSS and accessibility. I like building websites that solve real problems without compromising user experience. In my free time, you'll find me hacking on side projects and writing about code (when I'm not occupied with djent and progressive metal).

Mayank selected Trans Lifeline for an honorary donation of $50

Trans Lifeline

Trans Lifeline provides direct emotional and financial support to trans people in crisis, in the United States and Canada.