If you click on the search box and don't see a cursor, press the down arrow on your keyboard.
I had wanted to do something like this for a couple weeks, but I didn’t know how to get text input into CSS without JS. Then I read Terence Eden’s post on encrypting a static page with no JS, where I was horrified to learn that you can set a <style> element to display: block. Yikes.
The trick, then, was to visually hide all but the blank query-goes-here line.
body style{ display: block; width: 18rem; height: 2.8rem; white-space: pre; font-size: 2rem; line-height: 1; border: 4px solid white; padding: 4px; overflow: hidden;/* the rest of the CSS should be hidden */ }
/* hide the first line as best as possible */ body style:first-line{ font-size: 0; }
This is kind of quite fragile and finicky.
The cursor insertion point has to be at the right place. Even when clicking to give it focus, it’s not always right. (This seems to be worst in Firefox. If you don’t see a cursor, press the down arrow to move from the invisible first line to the correct blank/query line.)
If someone selects too much, deletes too much, or arrows down, the CSS both becomes invalid and you’ll see software gore.
The [attr*=] selector doesn’t have quotes around the query string, which means searches starting with numbers aren’t tokenized as strings. But it does mean that an “empty” search is deliberately an invalid selector, so it shows all results.
Accessibility? No 😬
So, uh, do not use it in production. But it’s still kinda neat I guess?
I love D3, but much of it was designed for SVG. When drawing a data visualization with SVG, you need to tell it exactly where and how big each item needs to be. That’s why D3 has layout utilities like scaleBand.bandwidth and stack(), and why you need to explicitly handle resizing. And when using D3 axis, it directly draws its own SVG.
But you don’t have to use SVG. You can take advantage of CSS’s grid and flex layouts to create many types of visualizations. I recently made an SVG-free bar chart that uses CSS to lay itself out as its container is resized and as data is added and removed — all without calculating any sizes using JavaScript.
The axes and axis labels should take up only as much space as they need, while the area for the graph itself takes up the rest of the place. That means giving axis and axis label rows and columns a dimension of auto, and letting the graph area take up the rest of the space with 1fr.
.graph{ display: grid; grid-template-columns: auto auto 1fr; grid-template-rows: 1fr auto auto; grid-template-areas: "y-axis-label y-axis graph-area" ". . x-axis" ". . x-axis-label"; }
(As an aside, I normally don’t reach for grid-template-areas, preferring to use grid-area: <start-row> / <start-col> / <end-row> / <end-col>, but in this case I think it’s nicely illustrative.)
We can now assign each of the graph elements to its respective grid area.
Note that I gave the graph-area an aspect ratio of 5:4. Since, as basically a block element, the inline size (a.k.a. width in left-to-right and right-to-left languages) of the .graph element is determined by its container, we can set the aspect ratio to ensure that it has a nicely proportional block size (a.k.a. height). The exact value is a matter of taste, but 5:4 works for me. Depending on your needs, you might size the container differently.
Axes
Now that we have our skeleton, let’s quickly take care of the axis labels before moving on to the axes themselves. I’m going to make them bold and align them center, and also add a bit of margin between the labels and the axes.
Now for the axes themselves, I’m going to make an important assumption: all ticks are evenly spaced. That means that what I’m about to do won’t work if you want to show certain specific ticks or are using a nonlinear (e.g., log) axis scale.
With that stated, let’s add some ticks to our markup. Later on, we’ll have D3 take care of this, but let’s do it manually for now so we can get them laid out and styled.
Because this is a bar graph, meaning that the x values are categorical and the y values are continuous, we’re going to treat the x and y axes somewhat differently. But we know they’ll have some things in common, so we can set those things up once.
To begin with, each axis is going to be a flex layout, but they get different flex directions. Similarly, both will have the same border, but they’re oriented differently, so the border will be along different edges, so all we can do is define a custom property to be assigned later.
The ticks themselves will also have many things in common, like font size, tick size, and tick color. We’ll also put a small gap between the tick label and the tick line.
But there’s a problem! The flex layout is taking into account the height of the tick labels themselves. To fix it, we have to give the tick a height of 0. That way, from a layout perspective a tick can be placed exactly where it’s supposed to be, but the tick content will happily overflow its 0-height container and look fine.
.graph .y-axis .tick{ height: 0; }
The x axis is similar, but being categorical, it’s typical to not have the bars go edge to edge like space-between; rather, they get laid out with space-around.
One other thing with the x axis is that when there’s a lot of data (or the graph is small) the tick labels can overlap. That isn’t always the case, but we can add an option to let someone turn the labels vertical by setting data-orientation="vertical" on the axis element.
Thinking a bit about the width and horizontal position of each data point, when there’s only a small number of data points, they should use the same space-around justification as the x axis labels (the data had better line up with the labels!), but should only grow to be so wide when there aren’t that many of them (we don’t want bars the width of the graph). But when there is a lot of data, the bars need to get narrower so more of them can fit.
To do this, we can have a grid create as many columns as there are data points, letting them get arbitrarily (well, 1 pixel) narrow, but only allowed to grow to 25% of the graph area. (Because there are no other columns, the remaining width and the total width are the same. That’s why .25fr is 25% of the total width.)
Remember from the markup above that each .datum has two children: a .value, where the text value goes (though this is optional), and a .bar. The value sits on top of the bar, so let’s use a column flex layout for the .datums (data?). Notice that each grid column in .graph-area is the full height of the graph area. That means that each .datum will also be the full height of the graph area. So, we can use justify-content: end to send the bar and value label to the bottom.
I also added a bit of inline margin here. This is what makes sure there’s space between the bars even when there are a lot of them. I used margin instead of a grid gap for a couple reasons. First, by keeping the spacing as part of each .datum rather than part of the layout we don’t have to compensate for it in the axis; everything stays nicely lined up. Second, we can use a percent value, which means that as you add more bars, the space gets narrower. This uses space more efficiently and feels more natural to me.
For the bar itself, you probably noticed in the .datum markup that its height is set on each bar. Beyond that, the bars get two additional styles. A color, of course (although that could also be set based on the data), and, importantly, flex-shrink: 0. We need that because if a bar gets tall enough that the value text hits the top of the graph area, by default the browser will squish the bar so the text fits. We don’t want that because the height represents a specific numeric value.
The value itself is pretty simple: center it with the datum. I also gave it a z index so that if the bars get close enough, and the value gets wide enough, the value will be on top of the adjacent bars. That said, if you find yourself in this situation, I’d recommend hiding the value label except when a bar is hovered over.
Did someone say D3? Ah yes, that was me a long time ago.
People think of D3 as a data visualization library, but that’s not quite right. It’s a library for binding data to DOM elements and manipulating those elements based on the data — and it happens to have a ton of utilities to help with visualization.
The class
So let’s build a JavaScript class that will build the DOM structure described above based on data.
To do this, we need a bit of D3. Not all of it, but we can import just the bits we need at the top. You’ll see how these all get used soon.
import{ select, selectAll }from"d3-selection"; import{ scaleLinear }from"d3-scale"; import{ max }from"d3-array";
I like the pattern of passing a container element and an object of optional options into a class constructor, then doing everything else from inside the class. Here’s our basic class definition:
Continuing in the constructor, the options object will handle axis labels and orientation, with some logical defaults. If an axis label is omitted, it’s set to an empty string. This is great, because if you don’t want a label, there simply won’t be one. This is a nice advantage over SVG: since the browser automatically sizes the axis label containers based on their content, if that content is "" it essentially collapses out of the layout. Compare this to SVG, where the idiomatic D3 way of leaving room for things like axis labels is manually adding some space to a margin = { top, right, bottom, left } object.
Then, our constructor builds the DOM. It gives the container a class of graph, creates the DOM tree, and applies the three options we just set up. It also assigns the three elements whose content will be dynamically built — xAxis, yAxis, and graphArea — to instance fields.
Note that with this approach, you can’t change the options later. We could easily change this by applying the options in the update() method I’ll describe below instead of here in the constructor.
Remember that the height of each bar is determined by a percent. The last thing the constructor does is create the only D3 scale we need: a scale that will map the values of our data to 0–100%.
this.y =scaleLinear().range([0,100]);
Next, let’s get some data. Our class will provide a getter and setter for the data.
The getter is simple enough: just return the data itself. The setter sets the data, of course, then does a couple more things. It sets the domain of our y scale, and it calls the (yet-to-be-defined) update() method.
There are two important things about setting the scale domain that I want to call out. First, rather than using d3-array’s extent(), it goes from 0 to max because you should make good bar charts. Second, notice the call to nice() at the end. This is because we don’t necessarily want the top of the y axis to be the exact maximum value of our data; we want it to be a nice round number. That’s exactly what nice() does for us.
Finally, we get to the heart of any D3 application: the update() method. But first, let’s state what the data has to look like. It’ll be an array of objects, and each object has (at least) a category and value field.
If you’re not used to thinking with joins that might be a little confusing. Basically, this selects all elements matching .tick in .x-axis. (To start, there should be none.) It then binds the data to them with the data() method. The second parameter to data() is a key so that it knows which data point is which should the data update later on. It then uses the join() method, which is a handy D3 shorthand for treating the enter and update selections the same. What that means is that for each row of data we’ll end up with a <div> with the class tick and text equal to the category — exactly what we want for the x axis.
The y axis is similar, but the data isn’t the data we’re trying to graph; we want a list of ticks. For that, we’ll rely on the D3 scale’s ticks() method. It takes an optional argument for the number of ticks, so we could be clever and set it based on the height of the container, but we can just stick with the default, which is 10. So this
this.y.ticks()
returns a list of 10 nice, evenly spaced ticks across the y scale’s domain. From there, we can have it add those ticks to the DOM the same way we did for the x axis:
Finally — finally! — we can actually graph our data. Because each .datum element has two children, it’s going to be a little different than how we did the x ticks. Rather than using the join() shorthand, we need to handle the enter and update selections separately. Specifically, when a new data point shows up (the enter selection), we need to create the .datum and its two children, .value and .bar. Then, for all data points, whether new or not (a merged enter and update selection), we want to update the value and bar height.
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.
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.
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.
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.
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.
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.
I remember my dad using a razor blade to splice in leader even after he was editing and mixing in Pro Tools. ↩︎
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:
It looks like a lot, but talking through it from top to bottom, left to right:
Call audio comes into the block labeled “Call far side” from the device called BlackHole 16ch because the call app is using it as its speaker.
Far side audio runs through a volume control (that I pretty much always keep at 100%), then directly on to my USB audio interface so I can hear it (the block labeled Monitoring)
My actual, physical microphone is plugged into my USB interface’s input, which shows up here as the input block labeled Mic.
The mic is a mono device on input 1 the USB interface, so there’s a Channels block to duplicate input 1 so it comes out on both channels.
As part of proper gain staging, I want to make sure that the gain knob of the interface is set correctly, so I stuck in a PFL meter block. I leave plenty of headroom and compress it later (more on that below), but want to make sure it’s high enough, too.
Then there are several blocks Audio Units plugins to process my mic audio:
First up is AUHipass (labeled HPF), which I use to get rid of low-frequency energy that’s really just rumble and other noise. I set it to roll off below about 50 Hz.
For reasons I haven’t figured out, I sometimes have hum in my signal, so I keep RX 8 De-hum around. I don’t love how it sounds, though, so unless the hum is particularly bad, I usually keep it off.
The RX 8 Voice De-noiser is pretty magical. HVAC noise? Outside noise? Nah. I turn on adaptive mode and leave the rest set to the defaults.
The final block of the processing chain in a compressor. I use MCompressor because it’s free. The exact settings depend on mic placement (sometimes I have the mic in the frame of my video because it sounds better, but sometimes I move it out of the frame because it looks nicer), so I have to adjust the threshold. I usually stick to a 4:1 ratio and turn on make-up gain (“Maximize to 0 dB”). Because of that latter setting, I also turn on the limiter.
Speaking of levels, next up is the AFL meter block. It should hopefully be hotter than the input meter, and more even. Still not clipping, though.
Next I have a couple of switches:
The one labeled “Self 🎧” toggles whether or not I want to hear myself in my headphones. (Usually I do, but that’s just personal preference.) When it’s on, it allows audio to travel from the mic processing chain on to the Monitoring block.
The one labeled “Mute 🎙️” toggles whether my mic is sent on to the call, which is the BlackHole 2ch output device.
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….)
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!
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. ↩︎
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.
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.
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.
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.
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.
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.killTweensOf(this);
// 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:
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=1 and settles at y=0. Multiplying by -1 will flip it so it starts at y=−1 and ends at y=0. Adding 1 to the whole thing will make it start at y=0 and end at y=1. So now our function is
y(t)=−e−tcos(2πt)+1.
Next we need to parameterize it. There are two parameters we care about: b, the damping coefficient, which determines how quickly the envelope approaches its asymptote[2]; and f, the frequency, which determines the number of jiggles before it settles down[3].
y(t)=−e−btcos(2πft)+1
For us, how to set f is a matter of aesthetics, but for simplicity it’s nice for b to be set so that when time t=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=5 gives the desired result. I then picked f=8 because I liked how the result looked when used as an easing function.
So our final easing function becomes:
y(t)=−e−5tcos(2π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; constdecayFn=t=>-1*(Math.E**(-b * t))* Math.cos(2* Math.PI*this.decayFreq * t)+1;
let sampling =true; let stopNextZero =false; const samples =[]; letT=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 returnline().curve(curveNatural)(samples); }
We can drop the resulting SVG string into the GSAP Custom Ease Visualizer to check it, and it works!
Finally, we can apply this easing function to a GSAP tween when “releasing” the underline:
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.
I later found that the d3-shape curveNatural generates an essentially identical curve. ↩︎
This kind of corresponds to CustomWiggle’s amplitudeEase. ↩︎
This kind of corresponds to CustomWiggle’s timingEase. ↩︎
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 😅 ↩︎
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.
And here’s my personal machine today (with the Dock visible for the photo op).
(I promise I’ve changed my desktop background a few times, but Hurricane Isabel from the ISS is in my regular rotation.)
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.
Colors: I set the system accent color to gray (aka Graphite) but keep the highlight color blue.
Clock settings: Day of week, no date, 24-hour clock, show the seconds.
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.
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
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.)
Revert a couple of recent design changes via accessibility settings: turn on persistent proxy icons and Reduce Transparency.
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)
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).
Install Alfred[2] and set it to the “macOS” theme. The ⇧⌥⎵ muscle memory for the launcher and ⌘⌥J for clipboard history are deeply ingrained.
Keyboard layout to Dvorak. (What can I say, I switched 20 years ago.)
And rounding out (pun intended) the I Hate Change category is Displaperture, which I use to round the menu bar on non-notched displays.
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. ↩︎
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.
What
This is a sine wave. It oscillates between -1 and 1 forever in both directions.
f1(x)=sin(x)
Let’s play with that sine wave. Recall from middle school algebra that if you add or subtract from x the graph essentially moves from left to right.
f2(x)=sin(x+ϕ)
A sine wave repeats every 2π in the x direction, so we can’t really say where it is in an absolute sense; an offset of π is the same as an offset of 3π. For that reason, the offset is described as a phase between 0 and 2π 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)f2(x)=sin(x+ϕ)f3(x)=f1(x)+f2(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.
How
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π) — assuming you use the right interpolation[1]. So for each point x between 0 and an arbitrary number of cycles (I chose 2), I calculated sin(x)+sin(x+ϕ) at 2π intervals. ϕ will ultimately be the phase offset determined by the scroll position, but for now we can pick an arbitrary value, like 1.
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.
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].
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.
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 π radians at both ends. I created a linear scale that maps the scrollY domain from 0–header height to a range of π–3π.
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.
Part 2, about the plucky nav underlines, is now up.
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 functions. ↩︎
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. ↩︎
My grandfather was a rock ’n’ roll radio DJ in the 1950s and 1960s, most notably at KYW/WKYC in Cleveland. In 1967, he and his family moved to Detroit for a job at WXYZ, but shortly thereafter the radio station changed formats and he lost his job. That led him to found the Specs Howard School of Broadcast Arts in 1970 to train others to be in the broadcasting industry. Over the years the school had a number of logos, but this is one of the originals, and — in my opinion — one of the best.
The logo is reminiscent of the Shure (where I work now) Unidyne 55. Being the third generation in my family with an interest in audio (my dad had a recording studio for many years), I have long felt a connection to the school and its history. The school was absorbed by Lawerence Technical University in 2021, and, sadly, my grandpa died in September of this year. My adaptation of the logo is a small tribute to that history.