Fluid Simulation Hero (Cappen-style ink over inverted type)
Goal
Build a full-screen landing hero with a giant white typographic headline and a GPU fluid simulation painted on top of everything. Moving the pointer injects swirling "ink" that flows, curls and dissipates like real fluid (a Navier–Stokes solver running entirely in fragment shaders). The fluid canvas uses mix-blend-mode: difference, so the moving white ink inverts whatever it passes over — black over the white page, white over the black headline — producing the signature liquid, self-inverting trail. The star of this piece is the fluid solver, not any DOM animation.
Tech
- Vanilla HTML / CSS / JS with ES module imports, bundled by Vite.
three(npm) is the only JS dependency — a WebGL fluid simulation written by hand in GLSL.- No GSAP, no ScrollTrigger, no SplitText, no Lenis. All motion is a
requestAnimationFrame
physics loop driving custom shaders. Do not reach for any animation library.
- Fonts loaded from Google Fonts: Inter (variable) and DM Mono.
Layout / HTML
Semantic, minimal. Body contains a fixed nav, a hero section, and a single fixed canvas.
<nav>
<div class="nav-logo"><a href="#">Vortex</a></div>
<div class="nav-links">
<a href="#">works</a>
<a href="#">about</a>
<a href="#">updates</a>
<a href="#">start a project</a>
</div>
</nav>
<section class="hero">
<div class="header">
<h1>Fluid System In</h1>
<h1>Constant Field</h1>
<h1>Of Interaction</h1>
</div>
</section>
<canvas id="fluid"></canvas>
<script type="module" src="./script.js"></script>
The three <h1> lines are staggered horizontally: line 1 left-aligned (default), line 2 align-self: flex-end (pushed right), line 3 align-self: center.
Styling
- Palette: page/hero background
#ffffff, text#000. Everything is black on white — the fluid
provides all the color via inversion.
- Type:
h1—font-family: "Inter",font-weight: 900,text-transform: uppercase,
font-size: clamp(3rem, 10vw, 15rem), line-height: 0.9, letter-spacing: -4%.
.nav-logo a— Inter,font-weight: 900,1rem, uppercase,letter-spacing: -2%..nav-links a—font-family: "DM Mono",font-weight: 500,0.85rem, uppercase,#000.- Reset:
* { margin:0; padding:0; box-sizing:border-box }. - nav — `position: fixed; top:0; left:0; width:100%; padding:2rem; display:flex;
justify-content:space-between; gap:1rem; z-index:2. .nav-links is display:flex; gap:4rem`.
- .hero — `position:relative; width:100%; height:100svh; padding:2rem; background:#fff;
display:flex; flex-direction:column; justify-content:center; overflow:hidden. .header` is a flex column.
- #fluid (critical) — `position:fixed; inset:0; width:100%; height:100%; pointer-events:none;
z-index:100; mix-blend-mode: difference. The pointer-events:none lets mouse events reach the page while the canvas still tracks them on window. The mix-blend-mode: difference` is what makes white ink read as black over the white page and invert the giant type where it crosses it.
- Responsive
@media (max-width:1000px)—.nav-linksbecomes a right-aligned vertical column
(flex-direction:column; align-items:flex-end; gap:0); all .hero h1 become align-self:center !important; text-align:center.
The star effect — real-time GPU fluid simulation (be exact)
Implement a stable-fluids Navier–Stokes solver on double-buffered (ping-pong) float render targets. This is a direct GLSL port; reproduce the pipeline, uniforms, and constants exactly.
Renderer & scene
THREE.WebGLRenderer({ canvas, alpha: true }).renderer.setPixelRatio(Math.min(devicePixelRatio, 2));renderer.setSize(innerWidth, innerHeight).- Cache
dpr = renderer.getPixelRatio(), and simulationwidth = innerWidth*dpr,
height = innerHeight*dpr. On window resize, re-setSize and recompute width/height.
- Scene with an
OrthographicCamera(-1, 1, 1, -1, 0, 1)and a single full-screen
Mesh(new PlaneGeometry(2, 2)) whose material is swapped every pass. A pass = set the quad's material, renderer.setRenderTarget(target ?? null), renderer.render(scene, camera).
Render targets
All targets: new THREE.WebGLRenderTarget(w, h, { type: THREE.HalfFloatType, depthBuffer: false }). A "double" is { read, write, swap() } where swap exchanges read/write.
aspect = width / height.- simSize =
{ w: simResolution, h: Math.round(simResolution / aspect) }. - dyeSize =
{ w: dyeResolution, h: Math.round(dyeResolution / aspect) }. velocity= double at simSize,pressure= double at simSize,dye= double at dyeSize.divergence= single at simSize,curl= single at simSize.
Config constants (use these exact values)
simResolution = 256
dyeResolution = 1024
curl (curlStrength)= 50
pressureIterations = 40
velocityDissipation= 0.95
dyeDissipation = 0.95
splatRadius = 0.3 // divided by 100 in the shader → 0.003
forceStrength = 8.5
pressureDecay = 0.75
threshold = 1.0
edgeSoftness = 0.0
inkColor = THREE.Color(1, 1, 1) // white
Shaders (all share one trivial vertex shader)
Vertex (all passes): varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position, 1.0); }. Fragment precision headers: precision highp float; (+ precision mediump sampler2D; for the sim passes). Write these nine fragment programs:
- splat — uniforms
sampler2D uTarget,float aspectRatio,float radius,vec3 color,
vec2 point. vec2 p = vUv - point; p.x *= aspectRatio; gl_FragColor = vec4(texture2D(uTarget,vUv).xyz + exp(-dot(p,p)/radius) * color, 1.0); (adds a Gaussian blob of color centered at point).
- advection — uniforms
sampler2D uVelocity, uSource,vec2 texelSize,float dt,
float dissipation. Semi-Lagrangian backtrace: gl_FragColor = vec4(dissipation * texture2D(uSource, vUv - dt*texture2D(uVelocity,vUv).xy*texelSize).rgb, 1.0);
- divergence — samples velocity at L/R/T/B neighbors (offset by
texelSize.x/.y), with a
boundary helper that clamps the sampled uv to [0,1] and negates the fetched velocity component when it goes out of bounds (free-slip walls). Output 0.5 * (vel(R).x - vel(L).x + vel(T).y - vel(B).y) in the red channel.
- curl —
texture2D(uVelocity,R).y - texture2D(uVelocity,L).y - texture2D(uVelocity,T).x + texture2D(uVelocity,B).xin red. - vorticity — uniforms
uVelocity, uCurl, texelSize, curlStrength, dt. Compute
vec2 f = normalize(vec2(abs(curl(T)) - abs(curl(B)), abs(curl(R)) - abs(curl(L))) + 0.0001) * curlStrength * curl(center); then gl_FragColor = vec4(texture2D(uVelocity,vUv).xy + f*dt, 0.0, 1.0); (vorticity confinement — adds the swirl).
- pressure — Jacobi iteration: neighbors clamped to
[0,1],
(pL + pR + pT + pB - divergence) * 0.25 in red.
- gradientSubtract —
velocity.xy - vec2(pR-pL, pT-pB)(project velocity to divergence-free). - clear —
gl_FragColor = value * texture2D(uTexture, vUv);(multiplicative fade of pressure). - display — uniforms
sampler2D uTexture,float threshold, edgeSoftness,vec3 inkColor.
float d = clamp(length(texture2D(uTexture,vUv).rgb), 0.0, 1.0); float a = edgeSoftness > 0.0 ? smoothstep(threshold - edgeSoftness*0.5, threshold + edgeSoftness*0.5, d) : step(threshold, d); gl_FragColor = vec4(inkColor, a); — with threshold=1.0, edgeSoftness=0.0 this is a hard step(1.0, d): opaque white ink only where dye magnitude ≥ 1, fully transparent elsewhere.
Input → splat
- Track
mouse = { x, y, velocityX, velocityY, moved }, all in device pixels. - On
windowmousemove(andtouchmove, withpreventDefault,{passive:false}), given
client x,y: velocityX = (x*dpr - mouse.x) * forceStrength, velocityY = (y*dpr - mouse.y) * forceStrength, then store mouse.x = x*dpr, mouse.y = y*dpr, set mouse.moved = true.
splat(x, y, vx, vy): set splat uniformsaspectRatio = width/height,
point = (x/width, 1 - y/height), radius = splatRadius/100. First splat into velocity: uTarget = velocity.read, color = vec3(vx, -vy, 0), render to velocity.write, swap. Then splat into dye: uTarget = dye.read, color = vec3(3,3,3) (bright white ink), render to dye.write, swap.
Simulation step order (per frame, simulate(dt)), simTexel = (1/simSize.w, 1/simSize.h)
- curl pass (velocity.read → curl).
- vorticity pass (velocity.read + curl,
curlStrength=50,dt) → velocity.write, swap. - divergence pass (velocity.read → divergence).
- clear pressure (
value = pressureDecay = 0.75) → pressure.write, swap. - pressure solve — loop
pressureIterations(40) times: setuPressure = pressure.read,
render to pressure.write, swap. (uDivergence set once before the loop.)
- gradientSubtract (pressure.read + velocity.read) → velocity.write, swap.
- advection of velocity (
uVelocity=uSource=velocity.read, simTexel,
dissipation=velocityDissipation=0.95) → velocity.write, swap.
- advection of dye (
uVelocity=velocity.read,uSource=dye.read,
texel = (1/dyeSize.w, 1/dyeSize.h), dissipation=dyeDissipation=0.95) → dye.write, swap.
Render + loop
render(): display pass withuTexture = dye.read,threshold,edgeSoftness,inkColor,
rendered to the screen (setRenderTarget(null)).
loop():dt = Math.min((Date.now() - lastTime)/1000, 0.016)(clamped to ~60fps step);
update lastTime. If mouse.moved, call splat(...) then reset moved=false. Then simulate(dt), render(), requestAnimationFrame. No easing curves — the fluid's dissipation factors (0.95 per frame) and the vorticity term ARE the motion feel: ink smears along the pointer path, curls into vortices, and fades out over ~1–2 seconds.
Assets / images
None. There are no image assets — the visual is 100% type + generated fluid.
Behavior notes
- Desktop pointer-driven; also handles
touchmove. There is no idle/auto animation — a still
pointer shows only the plain black-on-white hero; the ink appears and lives only while/after the pointer moves.
- The effect is continuous and unbounded (runs every frame forever); dissipation keeps it from
saturating.
mix-blend-mode: differenceon the canvas is essential and non-optional — without it the ink
would render as flat white rectangles instead of inverting the page and headline.
- Half-float render targets are required for solver stability; keep
depthBuffer:false.