What changed
  • The public-page backdrop is a wave front made of particles: a big arc that sweeps across the page
  • Particle size comes from a size field computed in the vertex shader, in one draw call
  • The pointer stirs the field and leaves a bounded wake; scroll position carries the front
  • It is decoration: it stops when the tab is hidden and draws one still frame under reduced motion

Updated 11 October 2026. The first version of this post described an earlier build, with a curl-noise flow field and a saturating pointer damper. That shader was removed on 8 October. This post now describes the backdrop that ships today (static/js/landing-scroll-orb.js).

The interactive backdrop behind CFIN's public pages is a field of small capsule-shaped particles. It is drawn with raw WebGL, in one draw call, with the work done in the vertex shader from each particle's home position and the time.

A wave front, not a ring

Earlier builds drew a ring or a blob. The current one draws a wave front: a large arc, with a radius of about 1.15 times the viewport height, whose centre roams mostly below and beside the screen. A curved front therefore crosses the page. A travelling swell band of bigger particles crosses it too, and the page's scroll position carries the front across. With a pointer, the front moves to pass through the hand.

High-DPI visual rendering and fluid dynamics on iPhone displayHigh-DPI visual rendering and fluid dynamics on iPhone display
The wave backdrop on an iPhone. Sample capture, not a measurement of frame rate.

Size comes from a field

Every particle's size is read from a field rather than set per particle: a narrow ring, a wide crest that grows toward the edge of the ring, a faint inner disc and slow noise blobs over the page. Below a threshold a particle is not drawn at all, which is why the page reads sparse and the front reads as a wave rather than a band of lines. In the shader, the size becomes the point size, with a floor of one pixel:

float sz = max(s * uK * uDpr, 1.);
vPx = sz;
gl_PointSize = sz;

The whole scene is a single call, gl.drawArrays(gl.POINTS, 0, count), over roughly 1,600 points at a 1440 by 900 window.

The pointer stirs it, within a cap

Moving the pointer makes the crest grow and the capsules fatten, then relax over a couple of seconds. It also leaves a wake of up to four impulses that push, swirl and fatten the particles they pass. Each impulse is capped so that a fast swipe cannot throw particles off the page:

var REST = .45, FAR = .55, INK = 1, WAKE_CAP = .095;
// ...
var c = Math.min(1, WAKE_CAP / v);   // v is the pointer speed: faster swipes are scaled down, never up

At a 1440-pixel window, one impulse never moves a particle more than about 86 pixels.

What it costs, and when it stops

The colours come from CSS custom properties on the layer and are re-read when the theme changes, so the same code serves light and dark. The sign-up page embedded in other sites does not run it.