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

What

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.

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\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(
0,
cycles * 2*Math.PI + Math.PI/2,
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))
.curve(d3.curveNatural);

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