Making a plucky web component
I recently decided to redo the plucky underlines with zero dependencies, namely removing GSAP and D3. My main goal was to reduce the weight of my JavaScript in order to speed up page download times, it also seemed like a good opportunity to encapsulate the JavaScript and CSS by making it a Web Component (aka custom element).
You can see the result on its project page, but I wanted to share a few experiences from building it.
Less JavaScript
First, I absolutely achieved my primary goal of reducing how much JavaScript I’m shipping to the client. Vite bundles all the JavaScript together, so it’s hard to know out exactly how much was the underlines and how much was the wavy header, but just the update to the underlines cut the client-side JS from 103kB (39kB gzip) to 47kB (17kB gzip).
I then went further and removed several unnecessary D3 dependencies from the wavy header, resulting in total client-side JavaScript of 14kB (5.49kB gzip). I’m very pleased with the size reduction!
Layout and CSS
An improvement I made to the previous implementation was getting rid of the need to measure the size of the element being underlined. I did this in two ways: first, I made the coordinate system go from 0 to 1 in both dimensions rather than relying on pixels by using viewBox="0 0 1 1', preserveAspectRatio="none", and vector-effect: non-scaling-stroke. (Here’s an example pen that shows the technique in action.)
And second, I made the entire layout entirely scaled around em typographic units. If you overlay text and a 1em high SVG with the SVG centered, the bottom of the SVG will be (from what I can tell; there may be exceptions?) at the baseline of the text. By combining those two things, there’s no need to know how big anything is in pixels.

The thickness of the underline is also sized in em. By default, though, I didn’t want the stroke to be thinner than 1px. Manual Matuzovic recently asked on Mastodon for interesting uses of the CSS max() function. Well, here’s an example: the default stroke width is max(.053em, 1px).
Dousing Lit with vanilla
For the last year or two I’ve been using a lot of Lit to build Web Components. Lit is great, but I really wanted this to have no dependencies. In order to help get my head back into vanilla Web Components, I referenced two examples: Zach Leatherman’s table-saw, and Chris Ferdinandi’s Web Component boilerplate. I didn’t follow either of them to a T, but both were helpful in remembering how to create a Web Component without additional tooling.
One difference is how I handled stylesheets. Chris’s boilerplate predates fairly widespread support for constructable stylesheets, and Zach’s cuts the mustard and doesn’t have a fallback for not using constructable stylesheets. Since global adoption is around 89%, I used them, but provided a fallback.
I also used private class members. This was maybe a poor choice, but I like it in theory at least!
Animation
At first, this seemed like a good opportunity to use the Animation API. In fact, I did the whole thing using it. But ultimately I redid it using a trusty old requestAnimationFrame() loop for two reasons. One is that you can’t make a totally custom easing function, which meant I had to sample the decay function[1] to make keyframes, then have it linearly interpolate between those keyframes. Not exactly elegant.
But the real fatal problem is that the Animation API can only animate CSS properties, and Safari somehow still doesn’t support using SVG presentation attributes (like a path d string) as CSS properties.
On the other hand, while custom easing functions may be more straightforward with an animation loop because you can use a JavaScript function, you lose any browser-provided (or GSAP-provided) easing functions. This meant having to look at what I had been using (power2.out) and trying to approximate it with math.
Maybe one day I’ll have a new creative idea, but in the mean time, I hope you enjoyed yet another blog post about the
See the original post for too many details on that. ↩︎