this space intentionally left blank

November 21, 2023

Filed under: tech»web»components

Chiaroscuro, or Expressive Trees in Web Components

Over the last few weeks, there's been a remarkable shift in the way that the front-end community talks about web components. Led by a number of old-school bloggers, this conversation has centered around so-called "HTML components," which primarily use custom elements as a markup hook to progressively enhance existing light DOM (e.g., creating tabs, tooltips, or sortable tables). Zach Leatherman's taxonomy includes links to most of the influential blog posts where the discussions are taking place.

(Side note: it's so nice to see blogging start to happen again! Although it's uncomfortable as we all try to figure out what our new position in the social media landscape is, I can't help but feel optimistic about these developments.)

Overall, this new infusion of interest is a definite improvement from the previous state of affairs, which was mostly framework developers insisting that anything less than a 1:1 recreation of React or Svelte in the web platform was a failure. But the whiplash from "this API is useless because it doesn't bundle enough complexity" to "this API can be used in the simplest possible way" leaves a huge middle ground unexplored, including its most intriguing possibilities.

So in the interest of keeping the blog train rolling, I've been thinking about writing some posts about how I build more complex web components, including single-page apps that are traditionally framework territory, while still aiming for technical accessibility. Let's start by talking about slots, composition, and structure.

Starting from slots

I wrote a little about shadow DOM in 2021, right before NPR published the Science of Joy, which used shadow DOM pretty extensively. Since that time, I've rewritten my podcast client and RSS reader, thrown together an offline media player, developed (for no apparent reason) a Eurorack-esque synthesizer, and written a social card image generator just in time for Twitter to fall apart. Between them, plus the web component book I wrote while wrapping up at NPR, I've had a chance to explore the shadow DOM in much more detail.

I largely stand by what I said in 2021: shadow DOM is a little confusing, not quite as bad as people make it out to be, and best used in moderation. Page content wants to be in the light DOM as much as possible, so that it's easier to style, inspect, and access for scripting. Shadow DOM is analagous to private properties or Symbol keys in JS: it's where you put stuff that only that element (and its user) needs to access but the wider page doesn't know about. But with the addition of slots, shadow DOM is also the way that we can define the relationships of an element to its contents in a way that follows the grain of HTML itself.

To see why, let's imagine a component with what seems like a pointless shadow DOM:

class EmptyElement extends HTMLElement {
  constructor() {
    super();
    var root = this.attachShadow({ mode: "open" });
    root.innerHTML = "<slot></slot>";
  }
}
This class defines an element with a shadow root, but no private content. Instead, it just has a slot that immediately reparents its children. Why write a no-op shadow root like this?

One (minor) benefit is that it lets you provide automatic fallback content for your element, which is hard to do in the light DOM (think about a list that shows a "no items" message when there's nothing in it). But the more relevant reason is because it gives us access to the slotchange event, as well as methods to get the assigned elements for each slot. slotchange is basically connectedCallback, but for direct children instead the custom element itself: you get notified whenever the elements in a slot are added or removed.

Simple slotting is a great pattern if you are building wrapper elements to enhance existing HTML (similar to the "HTML components" approach noted above). For example, in my offline media player app, the visualizer that creates a Joy Division-like graph from the audio is just a component that wraps an audio tag, like so:

<audio-visuals>
  <audio src="file.mp3"></audio>
</audio-visuals>

When it sees an audio element slotted into its shadow DOM, it hooks it into the analyzer node, and there you go: instant WinAmp visualizer panel. I could, of course, query for the audio child element in connectedCallback, but then my component is no longer reactive, and I've created a tight coupling between the custom element and its expected contents that may not age well (say, a clickable HTML component that expects a link tag, but gets a button for semantic reasons instead).

Configuration through composition

Child elements that influence or change the operation of their parent is a pattern that we see regularly in built-ins:

  • Media elements (audio, video, and picture) get live input configuration from <source>
  • Subtitles are also loaded by placing a <track> inside an audio or video tag
  • Selectbox options on mobile are native UI generated from child elements
  • SVG filters contain a list of operation elements, some of which have their own child config tags (think <fePointLight> for the lighting effects, or the <feFuncX> elements in a component transfer)

Tarot, Chalkbeat's social card generator, takes this approach a little further. I talk about this a little in the team blog post, but essentially each card design is defined as an HTML template file containing a series of custom elements, each of which represents a preset drawing instruction (text labels, colored rectangles, images, logos, that kind of thing). For example, a very simple template might be something like:

<vertical-spacer padding="20 0">

  <series-logo color="accent" x=".7" scale=".4"></series-logo>

  <vertical-stack dx="40" anchor="top" x=".4">

    <text-brush
      size="60"
      width=".5"
      padding="0 0 20"
      value="Insert quote text here."
      >Quotation</text-brush>

    <image-brush
      recolor="accent"
      src="./assets/Chalkline-teal-dark.png"
      align="left"
    ></image-brush>
    
  </vertical-stack>

  <logo-brush x=".70" color="text" align="top"></logo-brush>

</vertical-spacer>

<photo-brush width=".4"></photo-brush>

Each of the "brush" elements has its customization UI in its shadow DOM, plus a slot that lets its children show through. The app puts the template HTML into a form so the user can tweak it, and then it asks each of the top-level elements to render. Some of them, like the photo brush, are leaf nodes: they draw their image to the canvas and exit. But the wrapper elements, like the spacer and stack brushes, alter the drawing context and then ask each of their slotted elements to render with the updated configuration for the desired layout.

The result is a nice little domain-specific language for drawing to a canvas in a particular way. It's easy to write new layouts, or tweak the ones we already have. My editor already knows how to highlight the template, because it's just HTML. I can adjust coordinate values or brush nesting in the dev tools, and the app will automatically re-render. You could do this without slots and shadow DOM, but it would be a lot messier. Instead, the separation is clean: user-facing UI (i.e., private configuration state) is in shadow, drawing instructions are in the light.

Patchwork languages

I really started to see the wider potential of custom element DSLs when I was working on my synthesizer, which represents the WebAudio signal path using the DOM. Child elements feed their audio signal into their parents, on up the tree until they reach an output node. So the following code creates a muted sine wave, piping the oscillator tone through a low-pass filter:

<audio-out>
  <fx-filter type="lowpass">
    <source-osc frequency=440></source-osc>
  </fx-filter>
</audio-out>

The whole point of a rack synthesizer is that you can rearrange it by running patch cords between various inputs and outputs. By using slots, these components effectively work the same way: if you drag the oscillator out of the filter in the inspector, the old and new parents are notified via slotchange and they update the audio graph accordingly so that the sine wave no longer runs through the lowpass. The dev tools are basically the patchbay for the synth, which was a cool way to give it a UI without actually writing any visual code.

Okay, you say, but in a Eurorack synthesizer, signals aren't just used for audible sound: the same outputs can be used as control voltage, say to trigger an envelope or sweep a frequency. WebAudio basically replicates this with parameter inputs that accept the same connections as regular audio nodes. All I needed to do to expose this to the document was provide named slots in components:

<fx-filter frequency=200>
  <fx-gain gain=50 slot=frequency>
    <source-osc frequency=1></source-osc>
  </fx-gain>
  <source-osc frequency=440></source-osc>
</fx-filter>

Here we have a similar setup as before, where a 440Hz tone is fed into a filter, but there's an additional input: the <fx-gain> is feeding a control signal with a range of -50 to 50 into the filter's frequency parameter once per second. The building blocks are the same no matter where we're routing a signal, and the code for handling parameter inputs ends up being surprisingly concise since it's able to lean on the primitives that slots provide for us.

The Mask of the Demon

In photography and cinema, the term "chiaroscuro" refers to the interplay and contrast between light and dark — Mario Bava's Black Sunday is one of my favorite examples, with its inky black hallways and innovative color masking effects. I think of the shadow DOM the same way: it's not a replacement for the light DOM, but a complement that can be used to give it structure.

As someone who loves to inject metaphor into code, this kind of thing is really satisfying. By combining slots, shadow DOM, and markup patterns, we can embed a language in HTML that produces either abstract data structures, user interface, or both. Without adding any browser plugins, we're able to manipulate this tree just using the dev tools, so we can easily experiment with our application, and it's compatible with our existing editor tooling too.

Part of the advantage of custom elements is that they have a lower usage floor: they do really well at replacing the kinds of widgets that jQueryUI and Bootstrap used to provide, which don't by themselves justify a full single-page app architecture. This makes them more accessible to the kinds of people that React has spent years alienating with JS-first solutions — and by that, I mean designers, or people who primarily use the kinds of HTML/CSS skills that have been gendered as feminine and categorized as "lesser" parts of the web stack.

So I understand why, for that audience, the current focus is on custom elements that primarily use the light DOM: after all, I started using custom elements in 2014, and it took six more years before I was comfortable with adding shadow DOM. But it's worth digging a little deeper. Shadow DOM and slots are some of my favorite parts of the web component API now, because of the way that they open up HTML as not just a presentational toolkit, but also as an abstraction for expressing myself and structuring my code in a language that's accessible to a much broader range of people.

Past - Present