WebGL Fluid Simulation
A real fluid simulation on the graphics card: neon ink follows the cursor, swirls, fades, a click bursts a color and an invisible hand keeps stirring when nobody touches — WebGL2 with a WebGL1 fallback, sharp on Retina screens, paused off screen, a still image when the user reduces motion.
Updated
This effect is part of Effect.Labs — 840 vanilla effects, some free, some premium. Liquid has 31 effects, including 6 free. Explore the category →
3 usage examples



How it works
The .fx-0841-root block holds a 16/9 .wfl-scene (14 px corners, #0a0a0f background, crosshair cursor) with a <canvas class="wfl-canvas"> filling it, and a demo label that fades on hover. The script creates one fluid per .wfl-scene on the page and sets it up through attributes: data-wfl-palette (neon, aurora or mono with data-wfl-hue), data-wfl-keep (share of ink kept each second, 0.55), data-wfl-swirl (swirl strength, 22), data-wfl-bloom (light glow, 1), data-wfl-idle (invisible hand, 1) and data-wfl-ink (amount of ink, 1). Each scene also exposes scene.wfl.set(), burst(x, y), clear() and state().
The simulation follows Jos Stam's "stable fluids" method (1999), entirely in shaders. On every frame: the pointer injects velocity and ink along its path; vorticity confinement revives small swirls; pressure is solved with 18 Jacobi iterations (12 on a frame under 700 px) so the fluid does not compress; then velocity and ink are carried along by the flow. Velocity is computed on a grid of 128 cells on the short side, ink on 512 (384 under 700 px), the display at screen density (up to 2, within a limit of 1.8 million pixels). Dull mixes fade faster than vivid colors, and each new color stays close to the previous one on the hue wheel: the ink never turns brown or gray. The display adds a light relief lit from the top left, a 4-level glow on the brightest areas and a half-shade grain that prevents banding in gradients. Time steps follow the real clock: same speed at 60 and 120 Hz.
A click bursts a blot of 6 to 8 spinning jets. After 2.5 s without a pointer, the invisible hand takes over: a stroke drifting slowly across the frame and a burst every 2.5 to 5 s, so the background stays alive. Computing stops off screen (IntersectionObserver) and when the tab is hidden. A block hidden at load (tab, closed accordion) creates its buffers as soon as it gets a size. If the browser takes back the WebGL context, the script waits for it to be restored and starts again. Without WebGL, the scene gets .wfl-nogl: four still neon blots in CSS gradients, never an empty frame. Measured with the graphics card in Chromium and Firefox: 120 frames per second up to a 1,789 × 1,006 canvas, under 0.1 ms of JavaScript per frame; with software rendering (no acceleration), 49 frames per second at 640 × 360 and 25 at 1,789 × 1,006.
Accessibility
- Reduced motion: under
prefers-reduced-motion: reduce, the script paints a still image (five ink blots swirled in one go) and starts neither the loop nor the invisible hand; measured in Chromium, Firefox and WebKit, 0% of pixels change at rest. A visitor's gesture makes the fluid move for 1.5 s, then everything stops: motion only comes from them. A block hidden at load keeps this behavior when it shows up. - Screen readers: the canvas carries
role="img"and anaria-labeldescribing what is shown; the demo label isaria-hidden="true". As a decorative background under a heading, replace the label with a short description or set the canvas toaria-hidden="true". - Touch and keyboard: on a
.wfl-paintscene, the finger paints and the page does not scroll inside the frame (touch-action: none); without that class, the finger scrolls the page and only paints while touching. With a keyboard nothing happens: the effect is pure decoration, no information should depend on it. - Text on top: the CSS provides a banner template (
.wfl-scrim,.wfl-hero-content,.wfl-title,.wfl-btn) with a gradient overlay behind the text; underprefers-contrast: moreorprefers-reduced-transparency: reduce, the text moves onto a solid panel and the fluid is dimmed.
Browser compatibility
WebGL2 (RGBA16F textures) with a WebGL1 fallback (half floats, then floats, then encoded 8 bits), linear filtering redone by hand if the graphics card cannot filter floats: 38 KB of JavaScript, no dependency. Measured in Chromium 151, Firefox 153 and WebKit 26: WebGL2 active, canvas at 1,280 × 720 for a 640 × 360 scene on a Retina screen, paused off screen, still image under reduced motion, a scene hidden at load starting when shown, 0 errors. The WebGL1 fallback, forced in all three engines, runs the same. Without WebGL (tested in Chromium), the scene shows its still gradient.
Without WebGL (old browser, acceleration turned off), the scene gets .wfl-nogl: four still neon blots in CSS gradients. Without JavaScript, the frame stays black with its label. Without ResizeObserver, the script follows the window resize event.
The code
HTML structure to paste into your page (the CSS + JS come with your purchase):
<div class="fx-0841-root">
<div class="wfl-scene wfl-paint" data-wfl-palette="neon" data-wfl-keep="0.55">
<canvas class="wfl-canvas" role="img" aria-label="…"></canvas>
<div class="wfl-hint" aria-hidden="true">
<span>WebGL Fluid Simulation</span>
<span class="wfl-hint-sub">…</span>
</div>
</div>
</div>
One-time payment, no subscription — the code is yours forever
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
Palette (HTML — data-wfl-palette, data-wfl-hue) |
neon | neon runs from lime green to hot pink without ever going through red or yellow; aurora goes back and forth through green, cyan, blue, violet; mono plays on a single hue, chosen with data-wfl-hue from 0 to 1 (0.55: cyan blue, 0.8: violet). |
Ink persistence (HTML — data-wfl-keep) |
0.55: the ink keeps 55% of its strength each second | 0.8 leaves swirls that stay for several seconds; 0.3 fades quickly, for a background that keeps renewing under text. From 0.05 to 0.98. |
Swirls (HTML — data-wfl-swirl) |
22 | 10 to 15 for slow, broad swirls; 30 to 40 for a lively fluid full of small eddies. |
Light glow (HTML — data-wfl-bloom) |
1 | 0 removes the glow: a matte ink look, and a bit less work for the graphics card. |
Invisible hand (HTML — data-wfl-idle) |
1: takes over after 2.5 s without a pointer | 0 lets the fluid settle and fade out when nobody touches. The delay is set in the JS (IDLE_AFTER = 2500, in ms). |
Amount of ink (HTML — data-wfl-ink) |
1 | 0.6 for a discreet background behind text; 1.5 for denser, brighter strokes. |
Script control (JS — scene.wfl) |
set, burst, clear, state | scene.wfl.set({ palette: 'aurora', keep: 0.7 }) changes settings on the fly; scene.wfl.burst(0.5, 0.5) bursts a color at the center (coordinates from 0 to 1, origin at the bottom left); scene.wfl.clear() empties the frame. |
FAQ
.wfl-scene opens its own WebGL context, and browsers cap how many (around 16 in Chrome); only visible fluids compute. Three scenes on the same page run without trouble (measured in the examples). If the browser takes a context back for lack of memory, the script waits for it to be returned and rebuilds the fluid, including in a block that is hidden at that moment.data-wfl-bloom="0". With no WebGL at all, the scene shows its still neon gradient.