this space intentionally left blank

December 10, 2023

Filed under: tech»web»components

Cheap and Cheerful: trivial tricks for custom elements

I have one draft written about framework-free architecture, but it gets a little heavy, and I don't want to go there yet. So instead, here are some minor uses of custom elements that, for me, spark joy.

Set dynamic CSS variables

One of the things that cracks me up when I've been reading about React contexts is how they're almost always demonstrated on (and encouraged for) visual theming. Did nobody tell the developers that CSS... cascades? It's in the name! This is what custom properties are for! (Actual answer: they know and they don't care, because React was designed by people who would rather build countless levels of ideological abstraction than actually use the browser in front of them.)

CSS custom properties have been fairly well-supported since 2016, which is when Chrome and Safari shipped them. When I first started using them, they felt like a step back from the Less variables that I was used to, with the clunky var() syntax required to unwrap them, and calc() to operate on their contents. But custom properties are actually a lot more powerful because they do cascade — you can override them for sections of the DOM tree selectively — and because they can be updated on the fly, either using media queries or values from JavaScript.

Web components are a great way to take dynamic information and make it available for styling, since any CSS properties they set on themselves will then cascade down through the rest of the tree. Imagine we have the following markup:

<mouse-colorizer>
  <h1 style="color: var(--mouse-color, salmon)">
    This space intentionally left blank.
  </h1>
</mouse-colorizer>

If we don't do anything, the inner <h1> will be salmon-colored. But if we define the mouse colorizer element...

class MouseColorizer extends HTMLElement {
  constructor() {
    super();
    this.addEventListener("mousemove", this);
  }

  handleEvent(e) {
    var normalX = e.offsetX / this.offsetWidth;
    var normalY = e.offsetY / this.offsetHeight;
    this.style.setProperty(
      "--mouse-color",
      `rgb(${255 * normalX}, 255, ${255 * normalY})`
    );
  }
}

customElements.define("mouse-colorizer", MouseColorizer);

Now moving the mouse around inside the <mouse-colorizer>'s bounding box will set the color of the headline, between green, cyan, yellow, and white at each corner. Other elements inside this branch of DOM can also use this variable, for any style where a color value is valid: borders, background, shadows, whatever. There are lots of serious uses for being able to set dynamic cascade values, but I think it's just as valuable when it's a little mischievious:

  • Parallax effects that shift with the device orientation (GitHub's 404 page used to do this, but they seem to have dropped it lately)
  • Animations that progress as the page scrolls
  • Tinting images based on the time of day
  • Fonts that get just a little blurrier every time you touch the screen

While at NPR, I was working on a project for Louder than a Riot (RIP), one of the company's music podcasts. We wanted to give the page some life, and tie the visuals to the heavy use of audio samples. Animating the whole page with JavaScript was possible, but CSS custom properties gave us an easier solution. I hooked up the player to a WebAudio analyzer node, and had it dispatch events with the average amplitude of the sample window. Then, I set up a <speaker-boxxx> element that listened for those events, and set its --volume property to match. Styles inside of a <speaker-boxxx> could use that value to set color mixes and transforms (or any other style), so that when a track was playing, UI elements all over the page grooved along with it.

Wrap old libraries

A couple of weeks ago I needed a map for a story (people love maps, and although they're rarely the appropriate choice for data visualization, in this case it made sense). Actually, I needed two maps: the reporter wanted to compare the availability of 8th grade algebra over the last ten years.

My go-to library for mapping is Leaflet. It's long in the tooth at this point, and I'm sure that there are other libraries that would offer features like vector tiles or GPU-accelerated rendering. But Leaflet is fast, and it's well-proven, so I've stuck with it.

The thing is, if you need multiple maps, Leaflet can be kind of a pain. It works on the jQuery UI model, where you have to point it at an element and call a factory function to set it up, and then you have a map object that's separate from the DOM that it's attached to. It sure would be nice if the page just took care of that — I don't know, like a callback when the right element gets connected or something.

Well.

class MapElement extends HTMLElement {

  connectedCallback() {
    this.map = new leaflet.Map(this, {
      zoomSnap: .1,
      scrollWheelZoom: false,
      zoomControl: false,
      attributionControl: false
    });
    this.map.focus = () => null;

    leaflet.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png", {
      subdomains: "abcd".split("")
    }).addTo(this.map);
  }
}

Now I can get a list of maps just by running a query for leaflet-map elements, and populate them based on a filter value that I take from their data-year attributes. It's not that you can't write this in a DRY way, but it feels cleaner to me if I can just hand off the setup to the browser.

We talk a lot about using web components as glue between modern frameworks, but they're also really useful for wrapping up non-framework code that you don't want to think about. If you're maintaining a legacy site that uses older widget libraries, it's worth thinking about whether some of them can be replaced with custom elements to clean up some of the boilerplate and lifecycle management you're currently managing manually.

Add harmless interaction effects

When custom elements were first introduced, Google tried to make them more palatable by packaging them as Polymer, a library of tags and tools. It's hard to say why Google kills anything, but it didn't help that the framework leaned heavily on HTML imports, which (sadly) did not make it out of standardization.

I was pretty lukewarm on Polymer, but one thing I did like was that it had a set of paper-x tags that implemented bits of Material Design, like the ripple effect for clicks that you can still see in some Google UI. These are good candidates for custom elements, because they're client-only and if JavaScript doesn't load, they just don't do anything, but the page still works. They're an easy way to add personality to a design system.

Let's set up our own ripple layer tag as a demonstration. We'll start by creating some shadow DOM and injecting a canvas that's positioned absolutely within its container. We'll also register for pointer events, and bind the "tick" method that runs our animations:

var template = `
<style>
  :host {
    position: relative;
    display: block;
  }

  canvas {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    background: transparent;
  }
</style>
<canvas></canvas>
<slot></slot>
`;

class RippleLayer extends HTMLElement {
  ripple = null;

  constructor() {
    super();
    var root = this.attachShadow({ mode: "open" });
    root.innerHTML = template;
    var canvas = root.querySelector("canvas");
    this.context = canvas.getContext("2d");
    this.tick = this.tick.bind(this);
    this.addEventListener("pointerdown", this);
  }
}

With the infrastructure in place, we'll watch for pointer events and start a ripple when one occurs, storing the important information about when and where the click or tap occurred:

handleEvent(e) {
  this.releasePointerCapture(e);
  this.ripple = {
    started: Date.now(),
    x: e.offsetX,
    y: e.offsetY
  }
  this.tick();
}

Finally, we'll add the tick() method that actually draws the ripple. Our animation basically just looks at the current time and draws a circle based on where it should be at that point — we don't need to retain any information from one frame to the next.

tick() {
  // resize the canvas buffer 1:1 with its CSS size
  var { canvas } = this.context;
  canvas.width = this.offsetWidth;
  canvas.height = this.offsetHeight;
  // find out how far in the ripple we've gotten
  var elapsed = Date.now() - this.ripple.started;
  var duration = this.getAttribute("duration") || 300;
  var delta = elapsed / duration;
  // if the ripple is complete, don't draw and stop animating
  if (delta >= 1) return;
  // determine the size of the ripple
  var eased = .5 - Math.cos(delta * Math.PI) / 2;
  var rMax = canvas.width > canvas.height ? canvas.width * .8 : canvas.height * .8;
  // draw a darker outline and lighter inner circle
  this.context.arc(this.ripple.x, this.ripple.y, rMax * eased, 0, Math.PI * 2);
  this.context.globalAlpha = (1 - delta) * .5;
  this.context.stroke();
  this.context.globalAlpha = (1 - delta) * .2;
  this.context.fill();
  // schedule the next update
  requestAnimationFrame(this.tick);
}

Once this element is defined, you can put a <ripple-layer> anywhere you want the effect to occur, such as absolutely positioned in a button. This does require the "layer" to be on top — if you need to have multiple clickable items within a ripple zone, invert the tag by adding a slot, so that the layer wraps around content and adds an effect to it instead of vice-versa.

Past - Present