🌊 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.
Let’s play with that sine wave. Recall from middle school algebra that if you add or subtract from the graph essentially moves from left to right.
A sine wave repeats every in the direction, so we can’t really say where it is in an absolute sense; an offset of is the same as an offset of . For that reason, the offset is described as a phase between and 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.
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 ) — assuming you use the right interpolation[1]. So for each point between 0 and an arbitrary number of cycles (I chose 2), I calculated at intervals. 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].
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 radians at both ends. I created a linear scale that maps the scrollY
domain from 0–header height to a range of –.
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.
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 functions. ↩︎
In retrospect I could have done this with
d3.area()
, but I had originally intended to use twoclip-path
s, 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 ofline
. ↩︎