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.
Adapting this to React
Everything above describes a mount(config) that already returns exactly what a useEffect cleanup wants: it calls splashCursor() to get a fluid controller with its own destroy(), wires the six preset buttons and the attract-mode trace on top of it, and hands back one function that unwinds all three. What's missing isn't machinery, it's an owner — the dispatch that currently invokes mount() runs at import time, gated on nothing but window.MP, and every DOM lookup inside it assumes the whole document belongs to this component.
Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. Call mount() a second time without first running the cleanup the first call returned, and you get two of everything at once: two position: fixed; inset: 0 canvases stacked at the same z-index, each running its own WebGL2 context, its own solver loop, and its own window pointer listeners, so one mouse move splats ink into two independent dye fields on two independently-rolling rainbow timers — the colour under the cursor stops being a single hue. Both copies also wire the same six [data-water]/[data-dye] buttons, so one click toggles two solvers at once, and both run their own attract-mode trace over the same basin at the same time. None of this throws, and none of it reproduces in a production build — only development double-invokes the effect — which is exactly why it survives review: the ink still looks right, just doubled.
*(1) The entry point* — the module ends with if (window.MP && window.MP.register) { window.MP.register(...) } else { mount(Object.assign({}, KNOB_DEFAULTS)) }. window.MP is this catalogue's own editor bridge; it won't exist in a React host, so this always falls to the else branch and runs the instant the module is evaluated — before this component's own JSX, the header/basin/dial markup mount() depends on, has committed anything. Unlike a lookup that throws on a missing container, this one fails quietly in two different ways at once: splashCursor() doesn't need any of that markup — it defaults its own mount target to document.body and appends a canvas there directly — so the fluid overlay actually starts and tracks the pointer correctly from the very first paint, while document.querySelector(".hint")/".basin" return null and the document.querySelectorAll("[data-water]")/"[data-dye]" loops run over empty, already-captured NodeLists and wire nothing, permanently. The attract-mode trace doesn't even get that far: with stage undefined, stage?.getBoundingClientRect() is undefined too, so the very first scheduled frame satisfies !box and calls stopDrift() before a single splat is drawn. The ink works from the first paint; the buttons and the self-drawing hint never will. Move the whole body — dropping the window.MP dispatch entirely — into a useEffect with an empty dependency array, and only call it once this component's own markup is on the page.
*(2) Element lookups* — not every lookup here wants the same fix. The pointermove/pointerdown/touchmove/resize listeners splashCursor() binds to window and document are supposed to stay unscoped: this component's entire premise is a page-level overlay, and confining pointer tracking to a container would undo the one thing the rest of this prompt is about. What does need a root ref is the furniture mount() itself queries — .hint, .basin, [data-water], [data-dye] — since those are elements this component renders. Give the component a root ref, render the header/basin/dial markup under it, and query those four off the ref instead of document; during the StrictMode remount two copies of that markup exist for an instant, and an unscoped querySelectorAll will happily wire buttons on the copy that's on its way out.
*(3) Cleanup* — there's no GSAP, ScrollTrigger, Lenis, or SplitText anywhere in this file, and nothing here awaits a promise — no texture, no font, no fetch — so those variants don't apply. What has to survive the port intact is the teardown mount() already assembles out of two independent requestAnimationFrame loops and two independent sets of window listeners, and the discipline needed is narrow: use the exact function mount() returns as the effect's cleanup, not a shorter one written by hand.
Two rAF loops, not one
fluid.destroy() only unwinds what splashCursor() itself created: its own solver loop, its own pointermove/pointerdown/touchmove/resize listeners, its framebuffers, and — critically — the WebGL2 context itself, released through WEBGL_lose_context. It knows nothing about the attract-mode trace() loop mount() layers on top, which has its own requestAnimationFrame handle (drift), its own twelve-second setTimeout (expira), and its own pair of window hand-off listeners (tomaElRelevo, on pointermove and pointerdown) that exist only to notice a real pointer arriving. Cleaning up only fluid leaves all of that running: a second mount adds a second attract trace racing the first over the same basin, and the hand-off listeners keep multiplying on every remount. Worse, because fluid.destroy() is also the only thing that frees the live WebGL2 context, skipping it doesn't just leak DOM listeners — it leaks a GPU context per skipped remount, and browsers cap how many of those can exist at once; eventually a later mount's own canvas.getContext("webgl2", ...) call returns null and the whole effect goes dark, canvas and all, with nothing in the console to explain why. Call stopAttract() and offs.forEach((off) => off()) in the same cleanup, exactly as mount()'s own returned function already does — do not reach past it and call fluid.destroy() directly, or you keep the fluid and lose everything mount() built on top of it.
Where the canvas actually lives
The canvas is position: fixed; inset: 0, and config.mount defaults to document.body — splashCursor() appends it there itself rather than into any JSX tree. Keep doing that from inside the effect rather than rendering <canvas> bound by a ref: a fixed-position element's containing block becomes the nearest ancestor with a CSS transform, filter, or perspective instead of the viewport, so if this component's root ever ends up nested under an animated wrapper elsewhere in the app — a parallax section, a page-transition container — a JSX-rendered fixed canvas would start covering only that ancestor's box instead of the whole document, and "ink over the entire page" would quietly become "ink over one card." Appending straight to document.body (or to another explicit, untransformed node passed through config.mount) sidesteps that regardless of where the React component itself renders, and lets destroy()'s own canvas.remove() stay the only thing that ever touches its place in the tree.