∿ Waves! Part 2: plucky underline

Update 2023-10-31: I now have a zero-dependency version in a Web Component.

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.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:

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 😅 ↩︎