back

Let it snow – 12 Days of Web Components

December 13, 2023 – @hawkticehurst

Welcome to 12 Days of Web Components! A series of interactive blog posts that will introduce and explore the world of web components.

This series will cover the basics of web components, more complex and real-world examples, and discussions of the wider ecosystem/future of web components. These posts will also be littered with tricks and insights I’ve picked up along the way.

The result should be a series that will be in no way technically complete or robust, but will hopefully offer some fresh perspectives to both beginners and seasoned builders of web components alike.

I hope you enjoy!

Please note: This blog post is filled with interactive code examples. If you’re reading this from an RSS feed, I highly encourage you to open the original post.

A sneak peek

Today we’re going to start what will be a multi-day introduction to web components. This will lay the groundwork for some more fun and exploratory posts later on.

Here’s what we’re building today. A tiny portal into a winter wonderland. ☃️

Your first web component

To start with the absolute basics, “web components” is a loose term used to reference a set of evolving browser APIs that can be used to create custom HTML elements. You can think of web components as the browser-native way of building reusable user interfaces.

There are three primary browser APIs that are commonly used together to build web components:

In today’s post, we’ll just be discussing the custom elements API as it is the bare minimum needed to build a web component. Shadow DOM and templates, along with some other web component APIs, will be discussed in the coming days.

So what does the most basic web component look like? Like this:

<let-it-snow></let-it-snow>

<script type="module">
	class LetItSnow extends HTMLElement {
		constructor() {
			super();
		}
	}
	customElements.define('let-it-snow', LetItSnow);
</script>

This web component does absolutely nothing. It renders nothing and has no functionality. But it is a web component (or more precisely custom element). There are a few key ingredients to make note of:

1. A JavaScript class that extends the native HTMLElement interface

class LetItSnow extends HTMLElement {}

This is the defining trait of web components, they extend the built-in HTMLElement interface that all other HTML elements are built on top of.

A key takeaway to internalize is that web components are, definitionally, HTML elements. From the browser’s perspective, <let-it-snow> is just as much an HTML element as a <p> or <div> element.

The implications of this are extremely important. Web components do not follow the same rules, conventions, and mental models of a “component” you might find in React, Svelte, or Vue. Instead, they follow the rules, conventions, and mental models of HTML elements.

2. A class constructor method that immediately calls super()

constructor() {
	super();
}

According to JavaScript class semantics calling super() is how a class inherits from its base class –– that is, calling the super method will execute the code contained within the base class (i.e. HTMLElement). With super your custom element becomes an HTML element, but without it’s just a plain old JavaScript class.

3. The customElements.define API registers the component with the browser

customElements.define('let-it-snow', LetItSnow);

This is how your custom HTML tag name is associated with a custom element class. Without this API any instances of your custom element in an HTML document will have no functionality.

4. Custom element tags follow a specific naming convention

To avoid naming collisions with regular HTML elements, all custom elements must follow a few rules:

Note: Unfortunately, while the spec says that some names like <emotion-😍> are valid, in practice, it has been found that browsers do not support all possible conventions.

Web component architecture

Before we talk about interactivity there’s one more thing we need to cover: “HTML Web Components” versus “JavaScript Web Components.”

These names refer to the two major ways of building/architecting web components.

HTML web components are perhaps the simpler and more flexible way of building web components (and are what we will focus on in today’s post). The core premise of HTML web components is to write plain HTML and then wrap the parts you want to be interactive using a custom element.

These components only use the custom elements API and do not use any other APIs you might find in the “web components” bucket, such as shadow DOM or templates.

They look like this:

<counter-button>
	<button>Clicked <span>0</span> times</button>
</counter-button>

JavaScript web components, on the other hand, are historically what you might think of as “regular” web components. They use a wider variety of web component APIs to create components that are similar to single-page application (SPA) components, popularized by tools like React.

They look like this:

<counter-button></counter-button>

Said a different way, the HTML web component will hydrate/progressively enhance existing HTML, while the JavaScript web component will dynamically generate UI at runtime (using APIs like template and shadow DOM). There are benefits and drawbacks to both models, so it’s usually a case of picking the right tool (read: architecture) for the right job.

We’ll cover both architectures a fair bit during this series, but if don’t want to wait here are some great posts that talk more about HTML web components and some of the differences they have with JavaScript web components.

Adding interactivity

Okay, now it’s time to add some behavior!

Our <let-it-snow> component will include a button that toggles a snow animation, so we need to create a button that responds to a click event. This can be done like so:

<let-it-snow>
	<button>Let it snow</button>
</let-it-snow>

<script type="module">
	class LetItSnow extends HTMLElement {
		constructor() {
			super();
			this.button = this.querySelector('button');
			this.button.addEventListener('click', this.toggleSnow);
		}
		toggleSnow = () => {
			alert('No snow yet, but soon!');
		};
	}
	customElements.define('let-it-snow', LetItSnow);
</script>

This is also the point where we can add some basic component styles (hidden in a collapsible view).

Default component styles
let-it-snow {
	display: flex;
	justify-content: center;
	align-items: center;
	width: 100%;
	aspect-ratio: 2/1;
	border-radius: 6px;
}
let-it-snow button {
	font-family: inherit;
	font-size: inherit;
	font-weight: 500;
	color: white;
	background-color: rgb(95, 85, 236, 0.5);
	border: 2px solid rgb(95, 85, 236);
	border-radius: 8px;
	padding: 6px 10px;
	z-index: 1;
}
let-it-snow button:hover,
let-it-snow button:active {
	cursor: pointer;
	background-color: rgb(95, 85, 236, 0.7);
}
let-it-snow button:focus-visible {
	outline: solid 2px white;
	outline-offset: 2px;
}

You can try it out here:

There are two important things I want to highlight:

1. Using this.querySelector to access the children of your web component

When writing a web component class, this refers to the root web component node. We can take advantage of this to easily get a reference to the button inside <let-it-snow> by calling this.querySelector.

You can think of it as doing a search that will be scoped to your web component and its children. This stands in comparison to calling document.querySelector which will search the entire document. The former is quicker (i.e. a smaller DOM tree to search) and less error-prone. Nice!

2. Most event listeners should go in the constructor method

One thing we haven’t talked about yet is the fact that custom elements have component lifecycle methods similar to other popular component models. You can add custom code to the following callback methods that will be executed at specific times during the component lifecycle.

An extremely common pattern is to see event listener code being set up in connectedCallback and removed in disconnectedCallback.

class LetItSnow extends HTMLElement {
	constructor() {
		super();
		this.button = this.querySelector('button');
	}
	connectedCallback() {
		// Event listener is added when component is added the the DOM
		this.button.addEventListener('click', this.toggleSnow);
	}
	disconnectedCallback() {
		// Event listener is removed when component is removed the the DOM
		this.button.removeEventListener('click', this.toggleSnow);
	}
	toggleSnow = () => {
		alert('No snow yet, but soon!');
	};
}
customElements.define('let-it-snow', LetItSnow);

While this works, it’s unnecessary, error-prone (it’s common for people to forget to remove event listeners in disconnectedCallback), and technically incorrect.

According to the HTML Standard event listener code should be set in the constructor method.

In general, the constructor should be used to set up initial state and default values, and to set up event listeners and possibly a shadow root.

There’s a little bit more to say about this, but I’ve already written about it so I encourage you to read this blog post that goes into more depth on the topic.

An easy rule of thumb that I use, however, is:

Stateful styling with custom data attributes

To wrap everything up, let’s create our winter wonderland. This will be done almost entirely with CSS and take advantage of my favorite way of styling UI.

Custom data attributes are a method of storing extra information in HTML elements. You create them by prefixing any attribute name you want with data- and then store a corresponding value as a string –– for example data-gap="10px". When combined with CSS attribute selectors they can be used to create stateful styles in your UI.

If you want a short (and more fun) explainer about this topic, I made a TikTok and YouTube Short earlier this year that you can watch.

Also, to be extremely clear, custom data attributes are not considered to be a part of the “web component” label. I do, however, believe they are a wonderful pairing with HTML web components, so I’m giving them some attention here. Consider this one of the aforementioned tricks/insights I’ve gained along the way that I’m sharing now.

To add our winter wonderland, we can create a data-snow attribute (no value required in this case!) that will act as a boolean attribute that can be added and removed from <let-it-snow> to toggle the snow animation on and off.

We can do this by switching out the alert from earlier with toggleAttribute:

class LetItSnow extends HTMLElement {
	constructor() {
		super();
		this.button = this.querySelector('button');
		this.button.addEventListener('click', this.toggleSnow);
	}
	toggleSnow = () => {
		this.toggleAttribute('data-snow'); // <-- Here!
	};
}
customElements.define('let-it-snow', LetItSnow);

From here we can add the panning image animation (image courtesy of DALL·E):

let-it-snow[data-snow] {
	background-image: url('/imgs/winter-wonderland.png');
	background-size: 150%;
	background-repeat: repeat-y;
	animation: pan-y 10s linear infinite alternate;
}
@keyframes pan-y {
	from {
		background-position-y: top;
	}
	to {
		background-position-y: bottom;
	}
}

Here’s the result:

Finally, the snow! A new DOM node is needed to create the animation, so we’ll add an empty <div> as a child of our web component.

<let-it-snow>
	<button>Let it snow</button>
	<div class="snow" role="presentation"></div>
</let-it-snow>

The snow animation is an adapted version of Jon Kantner’s Snow in 262 Bytes of CSS:

let-it-snow {
	/* ... other let-it-snow styles ... */
	position: relative;
}
let-it-snow[data-snow] .snow,
let-it-snow[data-snow] .snow:before,
let-it-snow[data-snow] .snow:after {
	--move-x: 48px;
	--move-y: 144px;
	content: '';
	position: absolute;
	background: radial-gradient(#fff, #fff0 6%) 0 0 / var(--move-x) var(--move-x);
	width: 100%;
	height: 100%;
	animation: snow 4s linear infinite;
}
let-it-snow[data-snow] .snow:before {
	animation-duration: 3s;
}
let-it-snow[data-snow] .snow:after {
	animation-duration: 2s;
}
@keyframes snow {
	to {
		background-position: var(--move-x) var(--move-y);
	}
}

And… we’ve made a <let-it-snow> web component!

Admittedly, it’s a bit of a weird component, but hopefully, it was a fun and informative start to this series.

As I said at the beginning, this is day one of a series of posts that will be released every day for the next 12 days. In the next post, we’re diving a bit deeper into the basics of web components with templates.

Feel free to follow me on Mastodon for updates. See you tomorrow!