Dust on a wave

This is a hero I made for a product site: a band of coloured dust drifting across the top of the page like something caught in a beam of light. A few grains peel off and float upwards. Twelve of them are a little bigger and darker, and when you bring a finger or the pointer near one, it swells, the dust gathers round it, and it tells you its name.

The site itself isn't mine to show, so the labels here are new and neutral. The rest is the same code.

Move the pointer along the wave, or press and hold on a node. The wave slows down while you're holding one, so it's easy to catch.

The wave

Every grain has a fixed place along the wave (u, from 0 on the left to 1 on the right) and a fixed offset across it (v). Each frame, the wave's centre line is two sines added together, a long slow one and a short faster one going the other way. A third sine sets how thick the wave is at that point, from very thin to fat, and that is what makes it look like it's twisting rather than just bobbing.

The offsets are the sum of four random numbers, which bunches most grains near the middle and lets a few stray, and one in ten is pushed out further still. One in twenty is haze: a big, very faint square that softens the whole thing. A third of the grains ride a second copy of the wave, shifted in phase and a little fainter, so the band has depth.

How many grains depends on the size of the canvas: about one per 80 square pixels, starting from no fewer than 2,400 and no more than 7,500. If the first forty frames average more than 26ms, it drops to 60% of the grains and checks again, at most twice. The canvas resolution is capped at 1.5 times, which on a phone is the difference between smooth and not.

Fewer colour changes

The colour runs from amber on the left to sky blue on the right, through a six-stop gradient. Working that out per grain per frame would be wasteful, and so would telling the canvas to change colour thousands of times a frame. So the gradient is cut into 32 buckets once, every grain gets the bucket for its position (nudged a little so the bands don't show), and the grains are sorted by bucket. Drawing then changes fillStyle only when the bucket changes: for the dust, 32 times a frame instead of several thousand.

var BC = [];                                  // 32 colours, computed once
for (var i = 0; i < NB; i++) BC.push(colorAt(i / (NB - 1)));
P.sort(function (a, b) { return a.b - b.b; });   // grains grouped by bucket

for (i = 0; i < P.length; i++) {
  var p = P[i];
  if (p.b !== last) { cx.fillStyle = BC[p.b]; last = p.b; }
  where(p, t, time, tmp);
  cx.globalAlpha = p.a * (.7 + .3 * Math.sin(t / 1000 * p.sp * 2 + p.ph));
  cx.fillRect(tmp.x - p.s / 2, tmp.y - p.s / 2, p.s, p.s);
}

Squares, not circles. At one to three pixels nobody can tell, and fillRect is much cheaper than an arc.

Grains that rise

On top of the wave there's a second, smaller crowd: risers. Each one starts at the position of a random grain, takes that grain's colour, and climbs at 16 to 38 pixels a second with a gentle side-to-side wobble. It fades in over the first twelfth of its life and fades out as a × (1 − q)1.4, where q is how far through its climb it is. The power of 1.4 keeps it bright for longer and lets it go quickly at the end, which reads more like an ember than a linear fade does. When one finishes, it's recycled from a new grain, and on the first frame they all start at random points in their lives, so there's no opening burst.

Twelve nodes

The nodes ride the same wave as the dust, spread evenly from left to right. Every 2.8 seconds each one sends out two rings, the second a beat after the first, and each node starts 0.2 seconds after its neighbour, so the pings travel along the wave from left to right. That small rhythm is what makes them read as points that are alive rather than as bigger dust.

Within 54 to 90 pixels (12% of the width, clamped) the closest node starts to respond, eased in so it grows as you approach rather than snapping. It swells, its rings stop, and dust within about 52 pixels is pulled in towards it. Its name appears in a white halo so it stays readable over the dust, and a dashed line joins it to its pair, the node in the mirror position on the other side (first with last, second with second-to-last). The pair swells too, and it and any close neighbours show their names in small mono type.

The detail I'm happiest with is the slow-down. While a node is under the pointer, the wave's clock runs at as little as 35% speed, so the node doesn't slide away just as you reach it. On touch, the node holds for two seconds after you lift your finger, long enough to read it.

One change for this page: the original kept drifting at 40% speed when the system asks for reduced motion. Here it holds still instead, and the nodes still answer the pointer.

Built
October 2026
Stack
Canvas 2D, no libraries
Lives at
A product site's hero, and here
Size
About 10 KB of script

Built with Claude Code. The labels are made up; the dust is real.