🚇 CTA ‘L’ sign CSS

CSS has gotten to the point where things that, a few years ago, I would have used SVG and JavaScript for I can now do in just HTML and CSS. Being able to rely on the browser for layout, plus :has(), make things so much more flexible and responsive.

Last night I put together a quick demo of a Chicago Transit Authority ‘L’ sign generator.

See the Pen CTA ‘L’ sign CSS by Noah (@noleli) on CodePen.

There are a few things I like about this:

  1. The entire sign is sized based on the font size. aspect-ratio, combined with fr and em units, means that you set the font size, and the whole rest of the sign — its overall aspect ratio and the ratio between the colored bars and the rest of the sign — is proportionally correct.
    .sign {
    font-size: clamp(1.30rem, calc(-0.05rem + 6.73vw), 5.00rem);
    height: 1.8em;
    aspect-ratio: 8;
    grid-template-columns: 1fr 4fr 1fr;
  2. The train line color stripes are automatically laid out by a grid layout. Being able to add and remove stripes and have the rest of them adjust is very handy.
  3. As many others have noted, the :has() selector lets you easily hook up checkboxes and radio buttons to other parts of the DOM. It’s new, so it’s neat :)

Most of this isn’t exactly new, but when it all comes together it can be quite powerful.

Reminiscing about audio formats

The sound podcast Twenty Thousand Hertz asked people to share their memories about old audio formats. I doubt they’ll use mine on the show, but here’s what I shared.

Growing up in my dad’s recording studio in the 1990s I was exposed to a variety of audio formats. There was 24-track 2-inch tape, and 1/4” reel-to-reel tape that radio stations wanted commercials on until nearly the turn of the century[1] when they switched to DAT.

Via YouTube from user @FabricioBizu

But the most unique format was the DTRS format used by the Tascam DA-88. It used Hi8 video cassettes to record 8 tracks of digital audio. My dad linked 3 of them together for 24 tracks. As a kid, those machines were cool because when you turned them on they were programmed to marquee the word TASCAM across the 8 LED meters, and that was just fun to watch.

  1. I remember my dad using a razor blade to splice in leader even after he was editing and mixing in Pro Tools. ↩︎

Call audio update: Audio Hijack

With Audio Hijack 4, Rouge Amoeba added the ability to manually edit connections. I’ve been an occasional Audio Hijack user since 2004[1], but I knew right away that this had the potential to bring it into daily use.

As I wrote a couple years ago, I had been using a DAW, Reaper, to route and process my everyday call audio, but that seemed heavy, and definitely not what it was designed for.

Instead of Reaper, I set up Audio Hijack. As with before, I’m using BlackHole to create two virtual audio devices: a 2-channel device I use as the call app’s mic, and a 16-channel device I use as the call app’s speaker. Then, Audio Hijack looks like this:

An Audio Hijack 4 session that routes and processes my call audio. It's a series of blocks and connecting lines that I walk through in detail in the rest of this post.

It looks like a lot, but talking through it from top to bottom, left to right:

That’s it for mic routing, but there’s one more thing in the Audio Hijack session. Sharing application audio with a call can be tricky and unreliable (“Can y’all hear this?”), so I keep a block around that I can turn on to capture application audio and send it to both my ears and the call. If I can hear it, they can hear it. Plus, I didn’t have to un-share and re-share my screen to make it happen.

One other thing I’ve started doing is using a global shortcut to mute myself. Usually when I’m talking in a meeting, the call app isn’t in the foreground, and I like to be able to quickly toggle it because (let’s be honest), with a mic in my face, I worry about mouth sounds. (Unless you enable Original Sound in Zoom or a similar feature in other apps, this probably isn’t a huge concern, but still….)

Screenshot of MuteKey in my menubar with its settings open, showing that it's unmuted and other settings I'm about to describe

There are a gazillion of these little apps out there. They all seem just a little scammy. I’d like to use Mic Drop, but it doesn’t support muting only a single device at a time. I ultimately settled on MuteKey. It’s not perfect: I need to quit and relaunch it after the computer sleeps and/or it’s unplugged from the selected device, and sometimes after quitting while muted it turns down the input level, so I need to use SoundSource or Audio MIDI Setup to turn it back up. But it gets the job done. I use the shortcut Space because I can easily use it one handed with either hand — very handy for pointing with a mouse while talking on a call.

One of the reasons I like being able to hear myself in my headphones is that when the USB interface is muted with this app, I can’t hear myself either. So if I can’t hear myself, neither can the call, and if I can hear myself, the call can hear me, too.

So there’s the latest on my call audio setup. Let me know if you have any questions or suggestions on how to sound even better!

  1. In college I set Audio Hijack to automatically start and record the Real Audio (remember that‽) stream of Morning Edition on Michigan Radio from 05:00–07:00, then put it on my iPod to listen to throughout the day. Yep, I invented podcasting. ↩︎

Capitalism and the Big Bang

I finally read that Why the Super Rich are Inevitable piece that’s been going around. It reminds me a lot of something I’ve thought about for years:

The economy is like the early universe shortly after the Big Bang. You might think of matter as being uniformly distributed, but in reality small, random perturbations meant that some regions of space ended up with more matter than others. Over billions of years, the effect of gravity is that some places will have collected all the matter from surrounding areas.

Map of Cosmic Microwave Background radiation

The detailed, all-sky picture of the infant universe created from nine years of WMAP data. The image reveals 13.77 billion year old temperature fluctuations (shown as color differences) that correspond to the seeds that grew to become the galaxies. The signal from our galaxy was subtracted using the multi-frequency data. This image shows a temperature range of ± 200 microKelvin.

NASA / WMAP Science Team

The natural result of the Big Bang is that a relatively few areas of space will be extremely densely filled with matter (e.g., stars), while most of the universe will be almost entirely empty (e.g., intergalactic space).

As the yard sale model demonstrates, wealth distribution in a market economy is similar, with the rich getting richer by drawing capital from the less wealthy people around them until there are just a handful of oligarchs and a lot of poor people.

∿ Waves! Part 2: plucky underline

A few weeks ago I talked about the bottom of the header that gets wavy when you scroll. Now I’m going to talk about the main navigation links that “pluck” when you hover over them.

These underlines are examples of standing waves, waves with nodes and antinodes (peaks) that are fixed in space. For example, here is a standing wave with a period of 2 seconds.

Animation of standing wave in the stationary medium with marked wave nodes
Lucas Vieira, Public domain, via Wikimedia Commons

The only thing that changes is the amplitude of the wave, so I started out by creating a JavaScript object that generates an SVG path of a sine wave. You initially configure it with the number of half-periods you want, then, using a simple setter, it updates the amplitude every time you change the amp property.

set amp(newAmp) {
this._amp = newAmp;
this.path.setAttribute("d", this.generateWavePath());
get amp() {
return this._amp;

I implemented the the Bézier approximation of a sine wave used in the Wikimedia Harmonic partials on strings file[1].

One of the great things about GSAP is that it can tween any property of an object. In this case, I needed to tween amplitude values. The initial “pulling” of the line (when hovered) was easy: I picked an easing that looked natural and used GSAP to tween to the maximum amplitude value.

pull() {

// gsap is going to tween the amplitude of this Plucky object
gsap.to(this, { amp: this.maxAmp, ease: "power2.out", duration: this.pullDuration });

Now when you pull() the underline, it animates from whatever the current amplitude is to the configured maximum amplitude (I’m using 10px).

The release and subsequent decay was trickier because I wanted it to dampen and decay (what’s technically known as “jiggle-jiggle”) in a physically natural way. To do this, I needed an easing curve based on a damped sinusoid, as described in the Wikipedia Damping article:

Exponentially decaying cosine function
y(t)=etcos(2πt) y(t) = e^{-t}\cos(2\pi t) Nicoguaro, CC BY 4.0, via Wikimedia Commons

So let’s talk about easing functions. An easing function, like those from cubic-bezier.com or the GSAP ease visualizer, describes how a value changes over time, with both inputs and outputs to that function normalized to the range 0–1. As time progresses from 0 to 1, the value progresses from its start value (denoted 0) to a target value (denoted 1).

In this case, the goal is to tween from an amplitude of maxAmp (say, 10) to an amplitude of 0 (a flat line). The trick here is that overshooting the “end” of the ease (at which the easing function outputs 1 and our amplitude is 0) will result in a value that’s past the target of the ease. For us, that’s a negative amplitude. Negative amplitude means amplitude in the other direction, so what had been above the 0 line is now below. That’s exactly what we want to happen, as you can see in the standing wave animation at the top of this post.

I wanted to use the damped cosine above — or something like it — as a GSAP easing function. At this point, a normal person would have splurged on CustomWiggle, but that’s not what I did. Instead, it meant doing a few things: altering the function so its output value starts at 0 and ends at 1, setting a parameter that makes it settle down at 1 at time 1, setting a parameter for how many times it should jiggle before settling down, and transforming it into an SVG path string so GSAP can accept it as a custom easing function.

Looking at the plot above, we can see that it starts at y=1y = 1 and settles at y=0y = 0. Multiplying by -1 will flip it so it starts at y=1y = -1 and ends at y=0y = 0. Adding 1 to the whole thing will make it start at y=0y = 0 and end at y=1y = 1. So now our function is

y(t)=etcos(2πt)+1. y(t) = -e^{-t}\cos(2\pi t) + 1.

Next we need to parameterize it. There are two parameters we care about: bb, the damping coefficient, which determines how quickly the envelope approaches its asymptote[2]; and ff, the frequency, which determines the number of jiggles before it settles down[3].

y(t)=ebtcos(2πft)+1 y(t) = -e^{-bt}\cos(2\pi ft) + 1

For us, how to set ff is a matter of aesthetics, but for simplicity it’s nice for bb to be set so that when time t=1t = 1, it has just about settled down. (GSAP’s CustomEase will normalize any input to 0–1, but it was easier for me to think about if I did it this way.) Playing around with it in a graphing calculator a bit told me that b=5b = 5 gives the desired result. I then picked f=8f = 8 because I liked how the result looked when used as an easing function.

So our final easing function becomes:

y(t)=e5tcos(2π8t)+1 y(t) = -e^{-5t}\cos(2\pi 8t) + 1

The last step in turning this function into a custom easing curve was converting it into an SVG path string. I used more or less the same approach as I did for the wavy header: I sampled the function and ran the resulting values through d3-shape’s line() generator.

I probably over-engineered how I sampled the function, especially in how I came up with the stopping condition. I tried to be clever and only sample at/near[4] peaks and zero-crossings and to stop when peaks were no higher than 0.01. It works, though it makes it a bit hard to read.

calcDampCurve() {
// b: dampening coefficient. higher numbers dampen more quickly.
// fixing it at 5 has it reaching ~.01 by t = 1
// adjusting the decayFreq and releaseDuration allows for full flexibility
const b = 5;
const decayFn = t => -1 * (Math.E ** (-b * t)) * Math.cos(2 * Math.PI * this.decayFreq * t) + 1;

const samplesPerWavelength = 8;
const sampleRate = samplesPerWavelength * this.decayFreq;

let sampling = true;
let stopNextZero = false;
const samples = [];
let T = 0;
while(sampling) {
const t = T/sampleRate;
const y = decayFn(t);
samples.push([t, -y]);

if(T % samplesPerWavelength/2 === 0) { // near a local extreme
if(Math.abs(y - 1) < .01) {
stopNextZero = true;
if(stopNextZero && (T % samplesPerWavelength === 2 || T % samplesPerWavelength === 6)) { // at a zero crossing
sampling = false;
else {
T += 1;
// use d3-shape to turn the points into a path string for gsap
return line().curve(curveNatural)(samples);

We can drop the resulting SVG string into the GSAP Custom Ease Visualizer to check it, and it works!

Screenshot of the custom ease visualizer showing the desired damped cosine curve

Finally, we can apply this easing function to a GSAP tween when “releasing” the underline:

this.dampCurve = this.calcDampCurve();
if(!CustomEase.get("damped")) CustomEase.create("damped", this.dampCurve);

release() {
gsap.to(this, { amp: 0, ease: "damped", duration: this.releaseDuration });

Then hook up the pulling and releasing functions to the appropriate mouse events and that’s just about it.

this.container.addEventListener("mouseover", this.pull);
this.container.addEventListener("mouseout", this.release);

As with before, there were a few other things to take care of before calling it done, like respecting someone’s prefers-reduced-motion setting. In terms of progressive enhancement, I should point out that I made sure that the default link underline isn’t removed until the relevant JavaScript loads so that there’s always an appropriate affordance.

So there you go! That’s how I did my plucky nav links.

And as with before, be sure to check out the whole thing on CodePen.

  1. I later found that the d3-shape curveNatural generates an essentially identical curve. ↩︎

  2. This kind of corresponds to CustomWiggle’s amplitudeEase. ↩︎

  3. This kind of corresponds to CustomWiggle’s timingEase. ↩︎

  4. The peaks and 0 crossings aren’t exactly where they’d be if it were a pure (co)sine wave because changing the amplitude envelope creates higher harmonics. See Fourier for more details 😅 ↩︎

Moving In: What makes a computer feel like home

Last week was new computer day at work. As I was looking between the new machine and the old one, I was thinking about what makes a computer feel like mine. There are settings, little utilities, and how I arrange things that make it feel like home.

I’ve been shockingly consistent over the years. Here’s a screenshot from 2005.

Screenshot of my Titanium PowerBook in 2005 using largely the same configuration I use today

And here’s my personal machine today (with the Dock visible for the photo op).

Screenshot of my 13" 2015 MacBook Pro

(I promise I’ve changed my desktop background a few times, but Hurricane Isabel from the ISS is in my regular rotation.)

  1. Make things small by cranking up the scaled resolution. On a laptop that means the smallest Apple offers — or smaller. On my 13” personal machine I used a hack to enable 1920 × 1200 HiDPI. I don’t go full-native on my 27” external 4K display, but I do use the second-from-highest, 3360 × 1890.
  2. Colors: I set the system accent color to gray (aka Graphite) but keep the highlight color blue.
  3. Clock settings: Day of week, no date, 24-hour clock, show the seconds.
  4. Take basically everything out of the Dock (all I have there permanently is an editor to drag files to), turn off showing recent apps, and turn on auto-hiding. I also make it smaller, using the second-from-smallest tick when resizing while holding . But yes, I keep my Dock at the bottom.
  5. Non-default gestures and hot corners:
    • ExposéMission Control: 4 fingers up
    • App windows: 4 fingers down and top left corner
    • Move between spaces/full-screen apps: 4 fingers side-to-side
    • Paging: 3 finger swipe
    • Desktop: top right corner
    • Never sleep: bottom right corner
    • Display sleep: bottom left corner
  6. Moom with SizeUp-inspired keyboard shortcuts.
  7. Set up a keyboard shortcut () for Notification Center. (I didn’t have a Magic Trackpad for a while, so wanted a quick way to access it. Now it’s habit.)
  8. Revert a couple of recent design changes via accessibility settings: turn on persistent proxy icons and Reduce Transparency.
  9. Finder settings:
    • Turn on the status and path bars
    • Have new windows default to my documents folder (inside ~/Documents/)
    • Set search scope to the current window
    • Show all file extensions
    • Put the path item in the toolbar (though I usually end up -clicking the titlebar)
    • Windows default to list view (though I’m constantly switching between ⌘2 list and ⌘3 columns)
  10. Turn off the new window animation
  11. The menu bar: after the clock, I start out right to left with battery, wifi, volume, MenuMeters[1] average CPU graph, MenuMeters network activity graph, and Day One. Everything else is hidden by Bartender (with a handful of show-for-updates exceptions).
  12. Install Alfred[2] and set it to the “macOS” theme. The muscle memory for the launcher and J for clipboard history are deeply ingrained.
  13. Keyboard layout to Dvorak. (What can I say, I switched 20 years ago.)
  14. And rounding out (pun intended) the I Hate Change category is Displaperture, which I use to round the menu bar on non-notched displays.

  1. I also have iStat Menus, but I’ve been using MenuMeters since ~2004 and honestly I think it just feels more Mac-like and at home in the menu bar. ↩︎

  2. I’d much rather support an established indie Mac developer than an upstart awash in Silicon Valley money and culture. ↩︎

🌊 Waves! Part 1: scroll phaser

This website has a pretty boring design, with one small exception: waves. The bottom of the header gets wAvEy when you scroll the page, and the main navigation links at the top “pluck” when hovered.

It seemed like a delightful but subtle way to combine my interests in UX, audio signal processing, data visualization, and web development. That’s the why, but let’s dig into the details: what and how.

In this post, I focus on the header; in a future post I’ll get into the details of the navigation links.


This is a sine wave. It oscillates between -1 and 1 forever in both directions.

f1(x)=sin(x)f_1(x) = \sin(x)

Let’s play with that sine wave. Recall from middle school algebra that if you add or subtract from xx the graph essentially moves from left to right.

f2(x)=sin(x+ϕ)f_2(x) = \sin(x + \phi)

A sine wave repeats every 2π2\pi in the xx direction, so we can’t really say where it is in an absolute sense; an offset of π\pi is the same as an offset of 3π3\pi. For that reason, the offset is described as a phase between 00 and 2π2\pi rather of an absolute value outside that range.

When you add multiple waves together they interfere with each other. Such patterns can get quite complex, but when there are just two waves of the same amplitude and frequency, the interference pattern is rather simple. When the two waves are in phase (i.e., lined up), you get a wave that is twice the amplitude of the individual waves, and when they’re out of phase they sum to zero.

f1(x)=sin(x)f_1(x) = \sin(x) f2(x)=sin(x+ϕ)f_2(x) = \sin(x + \phi) f3(x)=f1(x)+f2(x)f_3(x) = f_1(x) + f_2(x)

So as the green wave changes phase, the orange wave (the sum of the blue and green waves) also changes. That’s what the bottom of the page header does: it traces the curve formed by summing two sine waves. As you scroll, one of the sine waves’ phases shifts relative to the other, creating a curve just like the orange line.


Note: The code samples here are out of context and are just intended to be explanatory. To see the whole thing in action, it’s on CodePen.

The gist of making the bottom of the header wavy is creating a CSS clip-path with that wavy shape. clip-path is a CSS property that lets you specify a shape where inside the shape, the element is visible, and outside is hidden. So by making a shape to clip the page header, you can make it look wavy. The shape can be specified using an SVG path string, which is what I did.

To start, let’s not worry about changing the phase or linking it to scrolling. Let’s just take a sine wave, add it to a phase-shifted sine wave, and get a path string.

Computers don’t do well with continuous functions. To get a sense of the shape of a sine wave, you have to sample it, measuring its value at a number of points along the way. The fewer points you can sample, the less computationally intensive the whole thing is, but at the cost of accuracy.

But it turns out you can get a pretty nice looking sine wave by only sampling at its maxima and zero-crossings (so, every π2\frac{\pi}{2}) — assuming you use the right interpolation[1]. So for each point xx between 0 and an arbitrary number of cycles (I chose 2), I calculated sin(x)+sin(x+ϕ)\sin(x) + \sin(x + \phi) at π2\frac{\pi}{2} intervals. ϕ\phi will ultimately be the phase offset determined by the scroll position, but for now we can pick an arbitrary value, like 1.

const cycles = 2;
const xValues = d3.range(
cycles * 2*Math.PI + Math.PI/2,
let phaseOffset = 1;
let values = xValues
.map(d => ({ x: d, y: Math.sin(d) + Math.sin(d + phaseOffset) }));

That gives the values, but we still need a shape to set the CSS clip-path. For that, there’s the always-useful D3-shape line. Configure it with an x scale that maps the x values above to 0–width of the page, a y scale that maps −2–2 to the desired amplitude (shifted to the bottom of the header), and an interpolation curve (d3.curveNatural). Then, put in the x and y values we just calculated, and out pops an SVG path string.

const xScale = d3.scaleLinear()
.domain([0, cycles * 2*Math.PI])
.range([0, widthPx]);
const yScale = d3.scaleLinear()
.domain([-2, 2])
.range([heightPx - 2 * amplitudePx, heightPx]);
const pathGen = d3.line()
.x(d => xScale(d.x))
.y(d => yScale(d.y))

let pathString = pathGen(values);

Now, that’s the just the bottom border of the header, but we want to it to be a closed area around the entire header, so we need to tack V0 H0 Z to the end of it[2].

Diagram showing a schematic of the header with the wavy path starting in the lower left, a line showing it being drawn from left to right, then showing how V0 draws a vertical line to y = 0, H0 draws a line to x = 0, and Z closes the path.

A little detail is that I didn’t want the waviness to change the overall height of the header or affect page flow, but the waves necessarily extend below the bottom edge. So, I had to make the whole header taller by the amplitude, then subtract the amplitude from the bottom margin.

The final operation is to actually set the CSS:

header.style("clip-path", `path("${pathString}")`);

Now all that’s left is to hook it up to scrolling. I used the scroll offset given by window.scrollY and mapped it to a phase for one of two sine waves (the green one above). To make the header flat when scrolled to the top and when scrolled to the height of the header, the phase offset at 0 needs to be an odd multiple of π\pi radians at both ends. I created a linear scale that maps the scrollY domain from 0–header height to a range of π\pi3π3\pi.

const phaseScale = d3.scaleLinear()
.domain([0, heightPx])
.range([Math.PI, 3*Math.PI]);

let phaseOffset = phaseScale(scrollY);

The naive way to listen for scroll events is to add an event listener to the document scroll event that directly updates the recalculates values, gets the path string, and sets it as the clip-path on the header. But you don’t want to do that because scroll events can fire faster than the frame rate at which the browser repaints, so instead I used this approach to debounce the updates. The scroll handler only tracks scrollY, and uses a requestAnimationFrame callback to do the more expensive operations.

There are a couple of other details, like respecting someone’s prefers-reduced-motion setting and using an intersection observer to only do all of this when the header is visible, but that’s about it! Now I have a header with a bottom border that, when scrolled, is a phase-shifting sum of sine waves.

Be sure to check out the whole thing on CodePen.

Part 2, about the plucky nav underlines, is now up.

  1. This sampling rate is 4 times the frequency, which is twice what the Shannon–Nyquist theorem says you need, but that assumes a very specific interpolation involving sinc\mathrm{sinc} functions. ↩︎

  2. In retrospect I could have done this with d3.area(), but I had originally intended to use two clip-paths, one of which was a rectangular gradient that covered everything outside the wavy area. That didn’t work because of a Safari bug I found with a similar approach, and my head was already thinking in terms of line. ↩︎

There is no vaccine for climate change

As I sat waiting the requisite 15 minutes to make sure I didn’t go into anaphylactic shock, I looked out over the arena and reflected on the historic nature of that moment. A building designed for basketball, concerts, and large-scale events sat empty for nearly a year because gatherings were a threat to public health. It had then been repurposed into a makeshift medical facility where vaccines were being administered on a mass scale. There’s something dark — and decidedly not normal — about a space designed for fun being used as a medical facility.

UIC arena sits empty as people get vaccines on the upper level

I felt grateful for the incredible work of the many brilliant and hard-working people both before and during the pandemic who allowed us to reach that moment. But fear of what this medical marvel might symbolize was also on my mind.

We had all been waiting for medicine to end the pandemic, any too many people had been ignoring epidemiology’s “inconvenient” non-medical interventions like social distancing and mask wearing. We had been passively waiting for science to save us with vaccines, and this time we got lucky: science delivered.

This technological solutionism, waiting for a technological savior instead of making sacrifices, is at play in climate change, too. I am absolutely thrilled that mRNA vaccine technology was practically ready and waiting to be applied to SARS-CoV-2 in record time, but it scares me that it reinforces a solutionist attitude: “See? Science saved us from the pandemic, so it’ll also save us from climate change!”

There is no vaccine for climate change.

We’ll need science to get us out of this, yes, but also political will. Political will to reign in corporations. Political will to fund science that can get us even partway there. Political will to do things that hurt in the short term before the status quo does even more damage to more people.

Had we heeded epidemiologists’ advice on COVID, millions of lives around the world could have been saved. Let’s not make the same mistake with climate change, squandering the remaining time we have while waiting for a scientific miracle.

Encode Mighty Things

On February 22, 2021, NASA landed the Perseverance rover on Mars. Encoded in the parachute used for landing was the JPL motto and Teddy Roosevelt quote, “Dare mighty things”. Despite being a secret before landing, the internet quickly decoded the message.

Perseverance parachute

I thought that was pretty cool, so decided to make a little site that lets you make your own parachute using the same encoding. Other people have explained the encoding better than I could, so without further adieu, here is Encode Mighty Things.

Encode Mighty Things