Let it snow
December 13, 2023 – @hawkticehurst
Welcome to 7 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:
- Custom Elements: A JavaScript API for creating custom HTML elements
- Shadow DOM: A JavaScript API that is primarily used for creating scoped styles and component slots (i.e. rendering children within a component template)
- HTML Templates: An HTML element (and associated JavaScript API) that typically acts as a way to define the template markup of a web component
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:
- Starts with a lowercase ASCII character
- Contains at least one hyphen
- Does not contain uppercase ASCII characters
- Does not contain some special characters as defined by the spec
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.
- HTML web components by Jeremy Keith
- HTML Web Components by Jim Nielsen
- HTML Web Components are Just JavaScript? by Miriam Eric Suzanne
- HTML Web Components by Chris Ferdinandi
- A year working with HTML Web Components by Hawk Ticehurst
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.
- connectedCallback – Called when a component is added to the DOM.
- disconnectedCallback – Called when a component is removed from the DOM.
- attributeChangedCallback – Called when a component attribute is changed, added, removed, or replaced.
- adoptedCallback – Called when a component is moved from one HTML document to another via the
document.adoptedNode
method.
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:
- Code that only needs to run once when the component is created (like configuring event listeners) should go in the
constructor
. - Code that needs to be run every time the component is added/removed from the DOM (like rendering tasks) should be put in
connectedCallback
anddisconnectedCallback
.
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
.
Follow me on Mastodon for updates. See you in the next one!
Next post: Templates and todo lists