Fluid Splash Cursor (page-level GPU Navier–Stokes overlay)
Goal
Build a page whose pointer drags liquid ink across the entire document — not inside a hero box, not behind the content, but on a transparent full-window layer that composites over headlines, tables, borders and photographs alike. Moving the pointer pushes a force and a puff of dye into a fluid simulation running on the GPU; the ink curls, spreads and dissipates on its own. Clicking bursts. The page underneath is never touched: no blend mode inverts it, no canvas covers it, no listener steals its clicks.
The demo dress is Nocturne Baths № 4, a municipal swimming pool that only opens after dark.
Tech
Vanilla HTML/CSS/JS. No library at all — not GSAP, not three.js, not a shader loader. Raw WebGL2 with a WebGL1 fallback, one requestAnimationFrame loop, ES module syntax so it drops into a bundler or a plain <script type="module"> unchanged. The whole solver is one exported function:
const fluid = splashCursor({ curl: 12, densityDissipation: 3, color: "#ff0000" });
If you are tempted to reach for three.js: don't. The entire GPU side is ten fragment shaders and a fullscreen quad — copy, clear, splat, advection, divergence, curl, vorticity, pressure, gradient-subtract and display — and pulling in a scene graph to draw a quad triples the bundle for nothing.
The mechanic
What a fluid solver actually is here
Two fields live in floating-point textures:
- velocity — a low-resolution vector field (128 on the short side is plenty; it is a force field, nobody sees it)
- dye — the visible ink, high resolution (1440 on the short side), because this one *is* seen
Every frame, in this exact order:
- curl — measure the local rotation of the velocity field
- vorticity confinement — push energy back into the small eddies the numerics keep eating, so the ink keeps curling instead of going smooth and dead. This is the
curlparameter, and it is the single knob that decides whether the effect reads as *smoke* or as *paint* - divergence — measure where the field is compressing
- pressure decay, then 20 Jacobi pressure iterations — solve for a pressure field that cancels that compression
- gradient subtract — remove the pressure gradient from velocity, which is what makes the fluid incompressible and therefore *swirl* instead of pile up
- advect velocity, then advect dye — semi-Lagrangian: for each texel look *backwards* along the velocity field and fetch what used to be there. Unconditionally stable at any timestep, which is why the effect survives a dropped frame
Each advection divides by 1 + dissipation * dt, and that is the whole of "fading".
Everything is double-buffered: you cannot read and write the same texture in one pass, so every field is a read/write pair that swaps after each blit.
The splat
A splat is a gaussian added to a target, written by one shader used twice:
vec2 p = vUv - point.xy;
p.x *= aspectRatio; // or the splat is an ellipse on a wide window
vec3 splat = exp(-dot(p, p) / radius) * color;
gl_FragColor = vec4(texture2D(uTarget, vUv).xyz + splat, 1.0);
First into velocity, where the "colour" is the force vector (dx, dy) * splatForce. Then into dye at the same point, where the colour is ink. Same shader, two targets — that is the whole input path.
The part that makes it an OVERLAY rather than a background
This is what separates it from every fluid hero you have seen. Three things, all load-bearing:
canvas.getContext("webgl2", { alpha: true, preserveDrawingBuffer: false });
float a = max(c.r, max(c.g, c.b)); // alpha comes from the ink's own brightness
gl_FragColor = vec4(c, a); // premultiplied
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
Unpainted water has zero brightness, therefore zero alpha, therefore the document shows through untouched. Painted water composites *over* it. And nothing ever clears the drawing buffer by hand: with preserveDrawingBuffer: false the browser wipes it to transparent black between frames, which is exactly the behaviour we want. Turn preservation on "to be safe" and the ink smears permanently across the screen.
The fourth thing is not a flag but a number: intensity, which scales every dye colour down to ~15%. At full value the splats are opaque paint and the page dies underneath them.
Shading
Optional, on by default, and cheap: take the gradient of the dye field's own brightness, treat it as a surface normal, dot it with a head-on light. The ink gains volume and stops looking like an airbrush. It is a #define in the display shader, so switching it needs a re-link, not a uniform.
Layout / HTML
A page-level overlay needs a page to be over. Three screens, all CSS — no images anywhere, so the only thing the fluid can be compositing with is ordinary document content:
<body>
<header class="chrome">…mark, three links, "Open until 02:00" with a pip…</header>
<main id="top">
<section class="basin">
<div class="tiles"></div> <!-- pool floor: two repeating-linear-gradients -->
<div class="lamp"></div> <!-- one blurred radial: a sodium lamp -->
<div class="basin-copy">
<p class="eyebrow">Municipal Baths № 4 · Ostend · since 1934</p>
<h1>The water is<br />warmest at<br /><em>one in the morning.</em></h1>
<p class="lede">…two sentences…</p>
<p class="hint"><span class="hint-dot"></span>Move your hand across the surface</p>
</div>
<div class="gauge">…four readouts: surface, air, salinity, swimmers…</div>
<div class="dial">
<span class="dial-label">Water</span>
<button data-water="still">Still</button>
<button data-water="wake" class="is-on">Wake</button>
<button data-water="riptide">Riptide</button>
<span class="dial-label">Dye</span>
<button data-dye="spectrum" class="is-on">Spectrum</button>
<button data-dye="sodium">Sodium</button>
</div>
</section>
<section class="waters">…four pools, each a row: numeral, name, temperature, depth…</section>
<section class="hours">…four opening slots in a hairline grid…</section>
<footer class="foot">…"Season keys are issued on the first cold night." + a pill CTA…</footer>
</main>
</body>
The tiled floor is not decoration. Dye over a flat field reads as a smudge; dye crossing grout lines reads as water. A 64px grid of 1px hairlines under a radial mask is enough, and it costs nothing.
Dress
Ostend, 1934, a glass-roofed pool open only between 21:00 and 02:00.
- Ground
#06090cnight,#0a1218basin,#0e1a22tile — nearly black, because the ink is additive and needs somewhere dark to glow - Ink
#e7edeebone,#78898fdim - Accent
#ffb45a, a sodium poolside lamp. Exactly one accent: the dye already brings every hue there is, and a second brand colour turns the page into noise - Type
Bricolage Grotesquefor the voice (wdth82,opsz96,wght620 on the display; the italic atwght380 for the amber line) andAzeret Monofor every readout, label and button. A municipal bath is signage and instrumentation before it is design - Display at
clamp(2.3rem, 5.4vw, 4.6rem), line-height 0.94, tracking −0.028em
Config
Everything is an option with a real default. The three presets on the page are the same solver, named after water instead of after parameters, because nobody browsing knows what vorticity confinement feels like until they see it:
const WATERS = {
still: { curl: 2, densityDissipation: 1.6, velocityDissipation: 1.4, splatForce: 4200, splatRadius: 0.28 },
wake: { curl: 12, densityDissipation: 3, velocityDissipation: 2.4, splatForce: 6000, splatRadius: 0.2 },
riptide: { curl: 34, densityDissipation: 4.4, velocityDissipation: 3.8, splatForce: 7400, splatRadius: 0.14 },
};
| option | default | what it does | |---|---|---| | simResolution | 128 | velocity/pressure grid, short side | | dyeResolution | 1440 | the visible ink. This is the expensive one | | densityDissipation | 3.5 | how fast the ink fades | | velocityDissipation | 2 | how fast the motion stops | | pressure | 0.1 | how much pressure survives each frame | | pressureIterations | 20 | below ~12 the flow goes blocky | | curl | 3 | vorticity confinement: the swirl | | splatRadius | 0.2 | | | splatForce | 6000 | | | shading | true | fake normals from the dye gradient | | colorUpdateSpeed | 10 | hue re-rolls per second while rainbow | | rainbow | true | cycle hues, or hold color | | color | "#ff0000" | used only when rainbow is false | | intensity | 0.15 | master dye level | | maxDpr | 2 | | | idleStopMs | 4000 | stop the loop this long after the last input | | respectReducedMotion | true | | | zIndex | 50 | | | mount | document.body | |
The controller it returns: { canvas, config, running, splat(x, y, dx, dy, color?), set(partial), destroy() }. splat takes CSS pixels in viewport coordinates; dx/dy are a push in the same units the solver uses internally, so compute them the way a real pointer does — travel as a fraction of the window, times splatForce — or a scripted trace hits ten times harder than a hand and the ink comes out as a bruise. The dye field is y-up and the DOM is y-down, so a push downward on screen is a *negative* dy; and correct both components by aspect ratio the way the internal handler does (dx *= aspect when aspect < 1, dy /= aspect when aspect > 1) or a vertical flick pushes harder than a horizontal one of the same length.
Behaviour worth building deliberately
Attract mode, and the cap it must have. A page whose only effect is pointer-driven shows *nothing* until someone moves the mouse — and on a phone, where there is no hover, possibly never. So the basin traces itself, on a Lissajous with two incommensurate frequencies so the stroke never retraces itself and never settles into a loop that piles dye into one blob:
const x = box.left + box.width * (0.44 + 0.40 * Math.sin(t * 5.6));
const y = box.top + box.height * (0.34 + 0.22 * Math.sin(t * 7.3 + 0.6));
fluid.splat(x, y, ((x - px) / innerWidth) * force, (-(y - py) / innerHeight) * force);
It ends in one of two ways, and both are required:
- a real
pointermoveorpointerdowntakes over — the invitation was answered, so fade the hint out with it and never come back; - or a hard time cap (12 s) — and this one is the whole ballgame. Every traced splat resets the solver's idle timer, so a trace with no end feeds the loop *for ever*, and it does that hardest in exactly the case the trace exists for: a phone nobody touches, left open at the top of the page. Without the cap, "leave it alone and the GPU work stops" is false on the one device where it matters. On this exit leave the hint up: nobody was there to read it.
Measured on the finished page, untouched: draws continue while the trace runs, keep going through the solver's four-second idle window, and hit zero from about 17 s onward.
The click burst. A pointerdown writes one splat, not a stroke: the same colour multiplied by 10 and a random kick of 10 * (Math.random() - 0.5) horizontally and 30 * (Math.random() - 0.5) vertically. It exists so that tapping does something on a touchscreen, where there is no hover to trail from. Seed the pointer's position from the same event first, then suppress the move-splat, or the tap fires twice.
Idle stop. Cancel the rAF four seconds after the last pointer input; any movement or splat() call restarts it. This is the difference between a decorative canvas and a laptop fan, and it is the single most valuable thing to add to any port of this effect.
Reduced motion. The ink keeps swirling and the hue keeps cycling after the hand has stopped, so this is autonomous motion, not a direct response to input. Under prefers-reduced-motion: reduce mount nothing — return the same shape of controller (canvas: null, running: false, no-op splat/set/destroy) so callers do not have to branch — and make sure the page reads without it. That includes hiding the hint in CSS: "Move your hand across the surface" invites an interaction that, under this setting, does not exist.
Cleanup. destroy() removes every listener, cancels the frame, deletes every framebuffer and texture, drops the two buffers and calls WEBGL_lose_context. Also listen for webglcontextlost and stop, rather than looping against a dead context.
Traps
- The first pointer event has no previous position. Its delta is measured from
(0, 0), which is the whole screen, which is one enormous splat wherever the pointer entered. Seed the previous coordinates on the first event and emit nothing. preserveDrawingBuffer: truesmears the ink permanently. It looks like a bug in the dissipation; it is not.- A resize keeps the ink and throws away the maths.
dyeandvelocityare copied into the new size with the copy shader — losing them means the picture blinks out mid-stroke — while pressure, divergence and curl are simply recreated at zero, because they are rebuilt from scratch every frame anyway. Free the targets you replace: a resize that only allocates leaks two textures and two framebuffers per double buffer, every time. - Clamp
dt.dt = min((now - last) / 1000, 1/60). Advection is stable at any timestep, but a tab that was backgrounded for two seconds hands you a two-second step and the whole field jumps. - **Device pixel ratio must be capped *and* recomputed.** A 3× phone buffer is 2.25× the pixels of a 2× one for no visible gain in a blurry fluid. And capturing the ratio once means dragging the window to a different display desyncs the pointer from the buffer — the classic version of this bug in every fluid port.
- Half-float render targets are not guaranteed. Probe each format with a 4×4 test attachment and fall back R16F → RG16F → RGBA16F. Without linear filtering, advection has to interpolate by hand (
#define MANUAL_FILTERING) and the dye buffer must drop to 256 or the page crawls. idleStopMsmust exceed the dye's visible lifetime. At any sane dissipation the ink is long gone before four seconds; pushdensityDissipationbelow ~0.5 and the last trail will freeze on screen until the pointer moves again.- Resolution is the SHORT side, and the long side follows the aspect ratio. Hard-code a square and splats become ellipses on a wide window.
pointer-events: noneon the canvas is not optional. It is a fixed, full-window element at z-index 50; without it the entire document becomes unclickable.- The overlay is only over what is below its z-index. A modal or a fixed nav at z-index 100 will sit on top of the ink and the effect will look like it stops at that element. That is why the z-index is a constructor option and not a constant.
- On a light ground, lower
intensity. The same value that reads as a glow on black reads as a stain on paper.
Definition of done
Move the pointer anywhere on the page and coloured ink follows it, curls behind it and dissolves — over the headline, over the pool table, over the footer, with the text still perfectly legible through it. Click and it bursts. Press *Riptide* and the same stroke becomes tighter and more filamentary; press *Sodium* and every hue collapses to one amber. Leave it alone for five seconds and the GPU work stops completely; move again and it resumes instantly. Under prefers-reduced-motion there is no canvas in the DOM at all and the page is still a page.