"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:
- Custom elements
- Shadow DOM
- Slots and templates
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:
- The button implements a two-row grid to place the toggle below the label.
- The toggle is created using the
::before
(for the "outer" border) and::after
(for the "inner" switch) pseudo-elements. [aria-checked='true']
is reused as a stateful semantic selector, avoiding the need for an additional class/attribute.
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:
- Store the controller in an instance field.
- Pass its abort signal when adding event listeners. The same signal can be reused for multiple event listeners.
- In
disconnectedCallback
, callabort()
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:
- We usually want to set the
mode
to "open" unless there is a good reason to prevent other JavaScript code from accessing this shadow tree. - Since
<body>
is a shared element, it may already have a shadow tree attached to it. We can check for it using the.shadowRoot
property. - For more robustness, it may be better to attach a shadow tree to another element and append that element to
<body>
. However, I'm using<body>
here for simplicity and to demonstrate slots in the next section.
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.
- This SVG will cover up the entire viewport, so its viewBox is based on
window.innerHeight
andwindow.innerWidth
. - Since it should not occupy any space, we can use
position: fixed
. Any CSS written inside shadow DOM is automatically scoped to the elements within this tree, so we'll just usesvg
as the selector. - Inside the SVG, we'll place a bunch of
<circle>
s at random spots and use<animate>
to make them move across the screen.
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!
Go further
#While we've covered most of the important bits of web components, there's a lot more to it!
- Custom elements can "react" to attribute changes using
observedAttributes
andattributeChangedCallback
. :defined
and:whenDefined
are great for improving the progressively enhanced experience of custom elements.- The
<template>
element can be used to store "inert" HTML and avoid having to create elements inside JS. - Named slots can be used to control which content fills which part of the template.
- Custom events can be dispatched from a custom element to communicate with other parts of the document.
- Declarative shadow DOM (once available cross-browser) will let us attach shadow trees without any JS!
- Shadow parts can be used to selectively allow external styling of nodes within a shadow tree.
- Constructable stylesheets provide a performant way to write CSS-in-JS.
- Server-first frameworks can be used to make shadowless web components portable.
- Most UI frameworks have good support for consuming web components. Some frameworks can also compile to web components, while others only allow writing web components.
Mayank selected Trans Lifeline for an honorary donation of $50
Trans Lifeline provides direct emotional and financial support to trans people in crisis, in the United States and Canada.