Developing with web components
Introduction
Web components have been supported in every major browser since 2018. Recently I rewrote my Color Combos website from vanilla JS to web components. This post covers a couple custom elements I wrote, and my thoughts on the API.
Why no shadow DOM?
My site’s styles weren’t built to support shadow DOM easily. Declarative shadow DOM is still experimental, which dampens my excitement for it.
Why no UI libraries?
I’m trying to keep the page size small. And it’s a good way to reinforce my knowledge of the web platform.
Custom elements
The basics of the custom elements API are pretty simple. I like that the API is declarative and doesn’t need any special build tools.
<cc-textarea-resizer>
We’ll start with the <cc-textarea-resizer>
element I wrote.
<cc-textarea-resizer>
<textarea
rows="4"
spellcheck="false"
class="sage-input w-100 resize-none code"
type="text"
name="fg"
></textarea>
</cc-textarea-resizer>
It’s a wrapper element that contains a <textarea>
element.
cc-textarea-resizer {
display: contents;
}
A single CSS rule is all we need to make the wrapper element disappear. This is a newish CSS property that’s useful for custom elements. This essentially removes the element from the DOM for the sake of CSS layouts.
Finally, let’s define the implementation of the custom element:
class HTMLCcTextareaResizerElement extends HTMLElement {
connectedCallback() {
this.addEventListener("input", this.#onInput);
for (const textarea of this.querySelectorAll("textarea")) {
this.#resize(textarea);
}
}
disconnectedCallback() {
this.removeEventListener("input", this.#onInput);
}
#onInput = (event) => {
this.#resize(event.target);
};
#resize(textarea) {
const value = textarea.value.split(/\n/).length;
const rows = Math.max(4, value);
textarea.rows = rows;
}
}
customElements.define("cc-textarea-resizer", HTMLCcTextareaResizerElement);
In the future you’ll be able to specify this
in CSS. For now we’ll use
progressive enhancement of the existing <textarea>
element via JS.
Note that the value 4
is the minimum number of lines. This could be
configurable through HTML attributes, but I didn’t think it was worth it.
I don’t have any serious complaints about the API for this use case. It’s verbose, but frankly I think classes are actually a good way to model elements. I wish that everything wasn’t so heavily built on inheritance, but that’s just how all the DOM APIs are built.
adoptedCallback
is called when the element is moved across iframes. I didn’t
need to use it, though.
I used private properties here for #onInput
and #resize
. I wanted to try
them out because they’re convenient, but Lea Verou has a great post on why
they can cause serious problems,
too.
<cc-form-output-item>
This element displays a single result from the color combos algorithm. It’s a box with some text in it, and a contrast calculation.
Because I’m rendering these in a loop from the parent element, I only create them via JS:
// Inside the <cc-form-output> element
const item = document.createElement("cc-form-output-item");
item.dataset.foreground = fg;
item.dataset.background = bg;
this.append(item);
I decided to use the
dataset
API to read data-*
attributes rather than using getAttribute
, mostly because
I like the syntax, but also out of a slight fear about what if the DOM spec
adds a new property with the name of one of my properties?
. Can you tell I
don’t like inheritance? Here’s the JS implementation:
import { split, getCombinations } from "../util.js";
class HTMLCcFormOutputElement extends HTMLElement {
static get observedAttributes() {
return ["data-foregrounds", "data-backgrounds", "data-group-by"];
}
attributeChangedCallback(name, _oldValue, newValue) {
this[name] = newValue;
this.#render();
}
get #templateEmpty() {
return document
.querySelector("#template--cc-form-output")
.content.cloneNode(true);
}
get foregrounds() {
return this.dataset.foregrounds || "";
}
get backgrounds() {
return this.dataset.backgrounds || "";
}
get groupBy() {
return this.dataset.groupBy || "background";
}
connectedCallback() {
this.#render();
}
#render() {
const combos = Array.from(
getCombinations({
foregrounds: split(this.foregrounds),
backgrounds: split(this.backgrounds),
groupBy: this.groupBy,
})
);
this.innerHTML = "";
if (combos.length === 0) {
this.append(this.#templateEmpty);
return;
}
for (const { fg, bg } of combos) {
const item = document.createElement("cc-form-output-item");
item.dataset.foreground = fg;
item.dataset.background = bg;
this.append(item);
}
}
}
customElements.define("cc-form-output", HTMLCcFormOutputElement);
This is also accompanied by a <template>
element and a little bit of CSS:
<template id="template--cc-form-output-item">
<div class="flex flex-column pa3">
<h3 class="mt0 mb2"><span data-name="contrast"></span> contrast ratio</h3>
<div class="pa3 ba round">
<p role="presentation" class="mt0 mb3">
In visual perception a color is almost never seen as it really
is—as it physically is. This fact makes color the most relative
medium in art. – <i>Josef Albers</i>
</p>
<pre class="ma0">foreground: <span data-name="fg"></span></pre>
<pre class="ma0">background: <span data-name="bg"></span></pre>
</div>
</div>
</template>
Most of the CSS comes from Tachyons:
cc-form-output-item {
display: block;
color: var(--foreground);
background: var(--background);
}
Custom elements are display: inline
by default, so I fixed that. I also made
the foreground and background colors configurable via CSS variables. I prefer to
limit my use of inline styles to just changing variables.
The big new thing here is observedAttributes
& attributeChangedCallback
. By
adding these you can make your element respond to HTML attribute changes. HTML
attributes are the key="value"
(or simply key
for boolean attributes) that
you can set directly in HTML. These represent basic configuration to your
element, and should be supported when possible. There’s also properties which
are set via element.property = value
in JS. These support complex data types
like arrays, objects, and functions. You should mirror the attributes as
properties for ease of use, e.g.:
const img = document.createElement("img");
// attribute
img.setAttribute("src", "https://example.com/image.png");
// property
img.src = "https://example.com/image.png";
It’s a lot of manual work to do this. A small library could go a long way for making custom elements more developer friendly. Due to how custom elements are loaded, there can be issues with using JS setters before a component is loaded, causing side effects to not trigger.
Calling this.#render()
in attributeChangedCallback
isn’t ideal. You may have
heard how React batches UI updates to avoid unnecessary re-renders. This is a
similar situation. If you’re updating many attributes at once, you don’t want to
re-render the element for each one. I didn’t bother to implement this, but it’s
something to keep in mind.
Overall thoughts
I struggled at various points. I’m very used to leaning on TypeScript to catch my mistakes these days. Using JS directly is certainly more challenging.
The custom elements API is pretty nice, all things considered. Reconciling HTML vs DOM is challenging. But web components have a lot of potential because they can be used directly from HTML.
I wish there was a bit less boilerplate, though. Implementing a reactivity system via setters is very time consuming, and mirroring attributes as properties (and vice versa) requires a lot of code. A bit of clever meta-programming could be combined with a custom base class to cover a lot of the boilerplate. I was hesitant to start building my own UI library for such a simple site, but the temptation was certainly there.
Of course, none of the rendering was especially complicated here either. Something like the lit-html template system could be helpful, but at that point you might prefer to use the entire Lit framework.
Maybe I’ll try out some of the more lightweight custom element frameworks in the future. I’ve already used Stencil for work, and it’s pretty nice. But I’d like to see something with no build step and no dependencies. Maybe in the future I’ll make my own just for fun.