---
name: fluid-simulation
description: Use when you want "Experimental, mesmerizing, exotic" - Simulates liquid flow and movement, often cursor-reactive.
---

# Fluid Simulation

> **Category:** Advanced WebGL / Canvas  -  **Personality:** Experimental, mesmerizing, exotic
>
> **Best use:** Hero backgrounds, creative portfolios
>
> **Ratings:** Professional 2/5 - Casual 4/5 - Exotic 5/5 - Visual impact 5/5 - Difficulty 5/5 - Performance cost 5/5

## What it is

A cursor-reactive field of coloured dye that swirls, smears and dissipates like ink dropped in water. It is a faithful *approximation* of Jos Stam's "Stable Fluids": every frame the previous colour buffer is **advected** (re-sampled upstream) along a velocity field, dimmed slightly, then fresh dye is **splatted** where the pointer is. The velocity field is the curl of an animated noise function (which gives the endless self-swirling) plus a pointer-driven push and vortex (which makes it follow and stir under your cursor). It runs as a GPU **ping-pong feedback loop** between two textures, so it stays cheap enough for a full-stage hero background.

It is unapologetically a "look at me" effect — perfect for a creative-studio hero or portfolio splash, overkill for a corporate dashboard.

## Dependencies / CDN

**None — vanilla JS + WebGL1.** No three.js, no float-texture extensions (the dye lives in plain `RGBA8` textures and the velocity is computed analytically in the shader, so it runs on baseline WebGL1). Only the display fonts are external and optional:

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Big+Shoulders+Display:wght@700;800&family=Familjen+Grotesk:wght@400;500;600&family=Martian+Mono:wght@400;500&display=swap" rel="stylesheet">
```

## HTML

Three stacked layers inside one rounded, clipped stage: the CSS fallback (bottom), the WebGL canvas (middle), and your content + a legibility scrim (top).

```html
<section class="fs-stage" id="fs-stage" aria-label="Interactive fluid dye. Drag your cursor to stir.">
  <!-- shown only if WebGL is unavailable -->
  <div class="fs-fallback" id="fs-fallback" aria-hidden="true">
    <span class="fs-fb1"></span><span class="fs-fb2"></span><span class="fs-fb3"></span><span class="fs-fb4"></span>
  </div>
  <canvas class="fs-canvas" id="fs-canvas" aria-hidden="true"></canvas>
  <div class="fs-scrim" aria-hidden="true"></div>

  <div class="fs-content">
    <!-- your hero markup: nav, headline, CTAs, a live readout (#fs-fps / #fs-vel), a #fs-hint -->
  </div>
</section>
```

## CSS

The structural essentials (the per-effect content styling is omitted for brevity):

```css
.fs-stage{position:relative;border-radius:26px;overflow:hidden;isolation:isolate;
  min-height:clamp(540px,72vh,680px);background:#04060d;display:flex}

/* layer 0 — CSS dye, always painted; revealed if WebGL fails */
.fs-fallback{position:absolute;inset:-22%;z-index:0;filter:blur(64px) saturate(150%);opacity:.92}
.fs-fallback span{position:absolute;border-radius:50%;mix-blend-mode:screen}
.fs-fb1{width:60%;height:72%;background:#16e0ff;top:-12%;left:-10%;animation:fs-drift 17s ease-in-out infinite}
.fs-fb2{width:64%;height:74%;background:#ff2d9b;bottom:-18%;right:-8%;animation:fs-drift 23s ease-in-out infinite reverse}
.fs-fb3{width:52%;height:64%;background:#7c3aed;bottom:-14%;left:30%;animation:fs-drift 28s ease-in-out infinite}
.fs-fb4{width:42%;height:54%;background:#ffb020;top:-6%;right:22%;animation:fs-drift 20s ease-in-out infinite reverse}
@keyframes fs-drift{0%,100%{transform:translate3d(0,0,0) scale(1)}50%{transform:translate3d(0,-28px,0) scale(1.13)}}

/* layer 1 — the real WebGL fluid */
.fs-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:1;display:block;
  touch-action:none;cursor:crosshair}

/* layer 2 — radial + vertical scrim so hero text stays legible over moving dye */
.fs-scrim{position:absolute;inset:0;z-index:2;pointer-events:none;
  background:radial-gradient(118% 92% at 20% 40%,rgba(2,4,11,.84),rgba(2,4,11,.42) 40%,transparent 68%),
            linear-gradient(180deg,rgba(2,4,11,.5),transparent 26%,transparent 70%,rgba(2,4,11,.66))}

.fs-content{position:relative;z-index:3} /* your hero sits on top */

@media (prefers-reduced-motion:reduce){ .fs-fallback span{animation:none} }
```

## JavaScript

The whole effect. Two GLSL programs (a simulation pass and a display/glow pass) plus a ping-pong loop. Pointer is mapped into `0..1` UV with the **Y axis flipped** to match WebGL.

```js
(function(){
  var stage=document.getElementById('fs-stage'), canvas=document.getElementById('fs-canvas');
  var fbEl=document.getElementById('fs-fallback');
  if(!stage||!canvas) return;
  var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
  function fallback(){ canvas.style.display='none'; if(fbEl) fbEl.style.opacity='1'; }

  var gl=null;
  try{ var o={alpha:false,antialias:false,depth:false,stencil:false};
       gl=canvas.getContext('webgl',o)||canvas.getContext('experimental-webgl',o); }catch(e){}
  if(!gl){ fallback(); return; }

  var VERT='attribute vec2 aPos;varying vec2 vUv;void main(){vUv=aPos*0.5+0.5;gl_Position=vec4(aPos,0.,1.);}';
  var PREC='#ifdef GL_FRAGMENT_PRECISION_HIGH\nprecision highp float;\n#else\nprecision mediump float;\n#endif\n';

  // SIM: advect previous dye along curl-noise + pointer velocity, dissipate, splat
  var SIM=PREC+
  'varying vec2 vUv;uniform sampler2D uDye;uniform vec2 uAspect,uPtr,uPVel;uniform vec3 uSplat;'+
  'uniform float uTime,uDtN,uActive,uSwirl,uPush,uVort,uDissip;'+
  'float hash(vec2 p){p=fract(p*vec2(123.34,456.21));p+=dot(p,p+45.32);return fract(p.x*p.y);}'+
  'float vnoise(vec2 p){vec2 i=floor(p),f=fract(p);float a=hash(i),b=hash(i+vec2(1.,0.)),c=hash(i+vec2(0.,1.)),d=hash(i+vec2(1.,1.));vec2 u=f*f*(3.-2.*f);return mix(mix(a,b,u.x),mix(c,d,u.x),u.y);}'+
  'float fbm(vec2 p){float s=0.,a=.5;for(int i=0;i<4;i++){s+=a*vnoise(p);p=p*2.02+11.3;a*=.5;}return s;}'+
  'vec2 curl(vec2 p){float e=.06;float x1=fbm(p+vec2(0.,e)),x2=fbm(p-vec2(0.,e)),y1=fbm(p+vec2(e,0.)),y2=fbm(p-vec2(e,0.));return vec2(x1-x2,y2-y1)/(2.*e);}'+
  'void main(){vec2 uv=vUv;'+
  ' vec2 vel=curl(uv*uAspect*2.6+uTime*0.05)*uSwirl;'+            // endless self-swirl
  ' vec2 d=(uv-uPtr)*uAspect;float infl=exp(-dot(d,d)/0.012);'+   // gaussian falloff around pointer
  ' vel+=uPVel*infl*uPush;'+                                      // push dye along pointer motion
  ' vel+=vec2(-d.y,d.x)*infl*uVort*length(uPVel);'+               // vortex/stir around pointer
  ' vec3 col=texture2D(uDye,uv-vel*uDtN).rgb;'+                   // semi-Lagrangian advection
  ' col*=uDissip;'+                                               // fade
  ' col+=uSplat*infl*(0.05+7.0*length(uPVel))*uActive;'+         // inject dye
  ' gl_FragColor=vec4(clamp(col,0.,1.),1.0);}';

  // DISPLAY: cheap bloom + tone + vignette
  var DISP=PREC+
  'varying vec2 vUv;uniform sampler2D uTex;uniform vec2 uTexel;'+
  'void main(){vec3 c=texture2D(uTex,vUv).rgb;'+
  ' vec3 b=texture2D(uTex,vUv+vec2(2.,2.)*uTexel).rgb+texture2D(uTex,vUv+vec2(-2.,2.)*uTexel).rgb+texture2D(uTex,vUv+vec2(2.,-2.)*uTexel).rgb+texture2D(uTex,vUv+vec2(-2.,-2.)*uTexel).rgb;'+
  ' c+=b*0.09; c=c/(c+vec3(0.62))*1.62; c=pow(max(c,0.0),vec3(0.88));'+
  ' vec2 q=vUv-0.5;c*=mix(0.7,1.0,smoothstep(0.96,0.32,length(q)));'+
  ' gl_FragColor=vec4(c,1.0);}';

  function sh(t,s){var x=gl.createShader(t);gl.shaderSource(x,s);gl.compileShader(x);
    if(!gl.getShaderParameter(x,gl.COMPILE_STATUS))throw gl.getShaderInfoLog(x);return x;}
  function prog(fs){var p=gl.createProgram();gl.attachShader(p,sh(gl.VERTEX_SHADER,VERT));
    gl.attachShader(p,sh(gl.FRAGMENT_SHADER,fs));gl.linkProgram(p);
    if(!gl.getProgramParameter(p,gl.LINK_STATUS))throw gl.getProgramInfoLog(p);
    p.aPos=gl.getAttribLocation(p,'aPos');return p;}

  var SIM_MAX=560, simW=2,simH=2, simP,disP, quad, tex=[],fbo=[], src=0, aspect=[1,1], U={};
  function makeTex(w,h){var t=gl.createTexture();gl.bindTexture(gl.TEXTURE_2D,t);
    gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE);
    gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,w,h,0,gl.RGBA,gl.UNSIGNED_BYTE,null);return t;}
  function buildSize(){
    var r=stage.getBoundingClientRect(), dpr=Math.min(devicePixelRatio||1,1.5);
    canvas.width=Math.round(r.width*dpr); canvas.height=Math.round(r.height*dpr);
    var sc=Math.min(1,SIM_MAX/Math.max(r.width,r.height));
    var nw=Math.max(2,Math.round(r.width*sc)), nh=Math.max(2,Math.round(r.height*sc));
    if(nw!==simW||nh!==simH){ simW=nw;simH=nh;aspect=[simW/simH,1];
      for(var i=0;i<2;i++){ if(tex[i])gl.deleteTexture(tex[i]); tex[i]=makeTex(simW,simH);
        if(!fbo[i])fbo[i]=gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER,fbo[i]);
        gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.COLOR_ATTACHMENT0,gl.TEXTURE_2D,tex[i],0);
        gl.clearColor(0,0,0,1);gl.clear(gl.COLOR_BUFFER_BIT);} src=0; }
  }
  function drawQuad(p){gl.bindBuffer(gl.ARRAY_BUFFER,quad);gl.enableVertexAttribArray(p.aPos);
    gl.vertexAttribPointer(p.aPos,2,gl.FLOAT,false,0,0);gl.drawArrays(gl.TRIANGLE_STRIP,0,4);}

  // pointer + palette state
  var px=.5,py=.5,lpx=.5,lpy=.5,pvx=0,pvy=0,tx=.5,ty=.5,active=0,lastMove=-9999,simTime=0,colT=0,splat=[.1,.89,1];
  var PAL=[[.10,.89,1],[.49,.30,.96],[1,.18,.61],[1,.69,.13]];
  function pal(t){var n=PAL.length,f=((t%1)+1)%1*n,i=Math.floor(f),a=PAL[i%n],b=PAL[(i+1)%n],k=f-i;
    return [a[0]+(b[0]-a[0])*k,a[1]+(b[1]-a[1])*k,a[2]+(b[2]-a[2])*k];}

  function step(dtN){ var dst=1-src;
    gl.bindFramebuffer(gl.FRAMEBUFFER,fbo[dst]); gl.viewport(0,0,simW,simH); gl.useProgram(simP);
    gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,tex[src]); gl.uniform1i(U.uDye,0);
    gl.uniform2f(U.uAspect,aspect[0],aspect[1]); gl.uniform1f(U.uTime,simTime); gl.uniform1f(U.uDtN,dtN);
    gl.uniform2f(U.uPtr,px,py); gl.uniform2f(U.uPVel,pvx,pvy); gl.uniform1f(U.uActive,active);
    gl.uniform3f(U.uSplat,splat[0],splat[1],splat[2]);
    gl.uniform1f(U.uSwirl,0.0042); gl.uniform1f(U.uPush,1.15);
    gl.uniform1f(U.uVort,2.3); gl.uniform1f(U.uDissip,0.9915);
    drawQuad(simP); src=dst; simTime+=0.016*dtN; }
  function show(){ gl.bindFramebuffer(gl.FRAMEBUFFER,null); gl.viewport(0,0,canvas.width,canvas.height);
    gl.useProgram(disP); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,tex[src]);
    gl.uniform1i(U.dTex,0); gl.uniform2f(U.dTexel,1/simW,1/simH); drawQuad(disP); }

  // map pointer -> UV (FLIP Y for WebGL), record velocity per frame
  stage.addEventListener('pointermove',function(e){ var r=stage.getBoundingClientRect();
    tx=(e.clientX-r.left)/r.width; ty=1-(e.clientY-r.top)/r.height; lastMove=performance.now(); active=1; },{passive:true});

  function init(){ simP=prog(SIM); disP=prog(DISP);
    ['uDye','uAspect','uTime','uDtN','uPtr','uPVel','uActive','uSplat','uSwirl','uPush','uVort','uDissip']
      .forEach(function(n){U[n]=gl.getUniformLocation(simP,n);});
    U.dTex=gl.getUniformLocation(disP,'uTex'); U.dTexel=gl.getUniformLocation(disP,'uTexel');
    quad=gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,quad);
    gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,1,1]),gl.STATIC_DRAW);
    buildSize();
    // seed so the field isn't black on load
    active=1; var pts=[[.30,.40,.05,.02],[.64,.56,-.04,.03],[.46,.30,.02,-.04],[.74,.42,-.03,-.02]];
    for(var s=0;s<pts.length;s++){ px=pts[s][0];py=pts[s][1];pvx=pts[s][2];pvy=pts[s][3];splat=pal(s*0.27);
      for(var k=0;k<7;k++)step(1);} pvx=pvy=0;active=0; for(var r=0;r<22;r++)step(1);
  }
  try{ init(); }catch(err){ console.warn('Fluid sim failed:',err); fallback(); return; }

  if(reduce){ show(); return; }   // reduced-motion: ONE static frame, no loop

  var last=performance.now();
  (function frame(now){
    var dtN=Math.max(.5,Math.min(2,(now-last)/1000*60)); last=now;
    if(now-lastMove>1500){ var at=now*0.00045;                 // ambient auto-stir when idle
      tx=.5+.30*Math.sin(at*1.7)+.11*Math.sin(at*.7+1); ty=.5+.27*Math.cos(at*1.3)+.09*Math.cos(at*2.1+.4); active=1; }
    lpx=px;lpy=py; px+=(tx-px)*.32; py+=(ty-py)*.32; pvx=px-lpx; pvy=py-lpy;
    colT+=0.0016+Math.min(.06,Math.hypot(pvx,pvy)*2.2); splat=pal(colT);
    step(dtN); step(dtN); show();                              // 2 substeps for smoother advection
    requestAnimationFrame(frame);
  })(last);

  addEventListener('resize',function(){try{buildSize();}catch(e){}});
})();
```

## How it works

1. **Two textures, ping-pong.** `tex[0]` and `tex[1]` hold the dye colour. Each frame the sim shader reads one and writes the other, then `src` flips. This feedback is what lets dye persist and evolve.
2. **Advection (the core trick).** For every output pixel, instead of pushing colour *forward*, the shader looks **backward** along the velocity (`texture2D(uDye, uv - vel*dt)`). This semi-Lagrangian step is unconditionally stable — the heart of Stam's "Stable Fluids".
3. **Velocity = curl-noise + pointer.** A divergence-free swirl comes from the **curl of fbm value-noise** (`curl()` samples the noise four times); the pointer adds a directional `uPush` (drag) and a tangential `uVort` (stir), both Gaussian-weighted by distance so influence is local.
4. **Dissipate + splat.** Colour is multiplied by `uDissip` (slow fade) and fresh `uSplat` dye is injected at the pointer, scaled by how fast it is moving — slow hover tints gently, fast flicks dump bright streaks.
5. **Display pass** upsizes the low-res sim texture with `LINEAR` filtering (which softens it into "liquid"), adds a 4-tap bloom, a filmic tone curve and a vignette.
6. **Decoupled resolution.** The simulation runs at ≤560px on the long edge (cheap); the display canvas runs at capped DPR. The blur from upscaling is a feature, not a bug.

## Customization

- **Palette:** edit the `PAL` array (cyan / violet / magenta / amber). `colT` walks the ring so strokes change colour over time.
- **Viscosity / persistence:** `uDissip` 0.985 (quick to clear) → 0.996 (long, oily trails).
- **Swirliness:** `uSwirl` is the ambient self-motion; `uVort` is how hard the pointer stirs; `uPush` is how hard it drags. Raise `uVort` for tighter vortices.
- **Splat size:** the `0.012` in `exp(-dot(d,d)/0.012)` — smaller = finer ink lines, larger = broad washes.
- **Resolution / cost:** raise `SIM_MAX` for crisper detail (more GPU), lower it for weaker hardware. Drop the second `step()` call to halve sim cost.
- **Glow:** the `*0.09` bloom weight and the `0.62 / 1.62` tone constants in the display shader.

## Accessibility & performance

- **Reduced motion:** when `prefers-reduced-motion: reduce`, the script seeds the field once, calls `show()` **once, and never starts the rAF loop** — a calm static frame. CSS animations on the fallback are disabled via the same media query.
- **Keep text legible:** the fluid is decorative (`aria-hidden`). A radial+linear **scrim** (`.fs-scrim`) sits between dye and copy, and the hero text carries a `text-shadow`; never rely on the dye staying dark behind text — it won't.
- **Cost is real (5/5).** Cap DPR (≤1.5), cap the sim texture (≤560px), and **pause when hidden** (`visibilitychange` → stop `requestAnimationFrame`) so background tabs don't cook the GPU.
- **No keyboard interaction** — that's fine because nothing is gated behind stirring; the idle auto-stir keeps it alive for non-pointer users and on touch.

## Gotchas

- **Flip the pointer Y.** Canvas/DOM coordinates are y-down; WebGL UV is y-up. Use `ty = 1 - (clientY-top)/height` or the dye appears mirrored vertically.
- **Declare fragment precision.** WebGL1 has no default float precision in fragment shaders — ship the `#ifdef GL_FRAGMENT_PRECISION_HIGH` guard or some GPUs refuse to compile.
- **`CLAMP_TO_EDGE` + `LINEAR` are mandatory.** Non-power-of-two feedback textures must clamp (REPEAT throws incomplete-texture); LINEAR is what gives the smooth liquid look.
- **`preserveDrawingBuffer:false`** means `readPixels`/`toDataURL` on the canvas returns blank after compositing — screenshot the page (the compositor) to capture a frame, don't read the GL buffer.
- **Re-seed after resize.** Reallocating the textures clears them; the field goes black until the loop (or auto-stir) repaints it. Expected — just don't panic.
- **Always provide the no-WebGL branch.** Wrap shader compile/link in `try/catch`; on any failure hide the canvas and reveal a CSS gradient so the hero never renders as a dead black box.
