WebGL Interactive Gradient
Goal
Build a full-screen hero whose entire background is a living, flowing multi-color gradient rendered in WebGL (Three.js + two custom fragment shaders). A real-time fluid simulation runs in a ping-pong pair of float render targets; a display pass warps a time-evolving trig-based gradient by the fluid's velocity field. Moving the mouse stirs the fluid — the gradient ripples, smears and swirls along the cursor's trail, then slowly relaxes back to its ambient flow. A minimal white nav, a centered logo image and a footer strip float on top. The star effect is the cursor-reactive fluid distortion of the animated gradient.
Tech
Vanilla HTML/CSS/JS with ES module imports. No GSAP is needed — all motion is a raw requestAnimationFrame loop driving shader uniforms. Use three (npm):
import * as THREE from "three";
import { vertexShader, fluidShader, displayShader } from "./shaders.js";
Put the three shader source strings in a sibling module shaders.js and export them as template-literal strings.
Layout / HTML
<nav>
<div class="logo"><p>Orbit Studio</p></div>
<div class="nav-items">
<p>Index</p><p>Portfolio</p><p>Info</p><p>Contact</p>
</div>
</nav>
<section class="hero">
<div class="gradient-canvas"></div>
<div class="hero-logo"><img src="/path/logo.png" alt="" /></div>
<div class="hero-footer">
<p>Experiment 0469</p>
<p>Built by Orbit Studio</p>
</div>
</section>
<script type="module" src="./script.js"></script>
.gradient-canvasis the WebGL container — JS appends the renderer's<canvas>into it..hero-logoholds a single centered logo image on top of the gradient.- Nav/footer copy is neutral demo text ("Orbit Studio", "Experiment 0469", etc.) — invent your own if you like.
Styling
Font import: @import url("https://fonts.googleapis.com/css2?family=Host+Grotesk:ital,wght@0,300..800;1,300..800&display=swap");
Global reset: * { margin:0; padding:0; box-sizing:border-box; } and body { font-family:"Host Grotesk"; }
img:width:100%; height:100%; object-fit:cover;p:color:#fff; font-size:0.9rem; font-weight:450;— all UI text is small white grotesk.nav, .hero-footer:position:absolute; left:0; width:100vw; padding:2rem; display:flex; justify-content:space-between; align-items:center; z-index:2;(nav sticks to the top by default flow; footer addsbottom:0)..nav-items:display:flex; gap:4rem;nav .logo p:font-weight:700;section(the hero):position:relative; width:100vw; height:100svh; overflow:hidden;.hero-logo:position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:25%;— the logo spans 25% of the viewport width, dead center..gradient-canvas:position:absolute; top:0; left:0; width:100%; height:100%;- Responsive:
@media (max-width:1000px) { nav { flex-direction:column; gap:2rem; } }
The effect (Three.js fluid-distorted gradient — be exact)
Config object (single source of truth, re-synced to uniforms every frame)
const config = {
brushSize: 25.0,
brushStrength: 0.5,
distortionAmount: 2.5,
fluidDecay: 0.98, // how long fluid velocity persists (per-frame multiplier)
trailLength: 0.8, // per-frame multiplier on the trail/pressure channel
stopDecay: 0.85, // extra damping near a stationary cursor
color1: "#b8fff7", // pale mint
color2: "#6e3466", // muted plum
color3: "#0133ff", // vivid electric blue
color4: "#66d1fe", // light sky blue
colorIntensity: 1.0,
softness: 1.0,
};
Add a hexToRgb(hex) helper returning [r,g,b] in 0–1 for the color uniforms.
Scene setup
camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);— full-screen quad rig, no perspective.renderer = new THREE.WebGLRenderer({ antialias: true });sized towindow.innerWidth × window.innerHeight, appended into.gradient-canvas.- Two ping-pong render targets (
fluidTarget1,fluidTarget2), bothwindow.innerWidth × window.innerHeightwith:
``js { minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter, format: THREE.RGBAFormat, type: THREE.FloatType } ` FloatType` is required — the sim stores signed velocities.
- Track
currentFluidTarget/previousFluidTargetrefs plus aframeCountinteger (starts 0). geometry = new THREE.PlaneGeometry(2, 2);shared by two meshes:fluidPlane(withfluidMaterial) anddisplayPlane(withdisplayMaterial). No scene graph needed — render each mesh directly withrenderer.render(mesh, camera).
fluidMaterial (ShaderMaterial) uniforms: iTime (float), iResolution (Vector2 = window size), iMouse (Vector4 = x, y, prevX, prevY, initialized 0,0,0,0), iFrame (int), iPreviousFrame (sampler, starts null), uBrushSize, uBrushStrength, uFluidDecay, uTrailLength, uStopDecay — all seeded from config. Vertex = vertexShader, fragment = fluidShader.
displayMaterial (ShaderMaterial) uniforms: iTime, iResolution, iFluid (sampler, starts null), uDistortionAmount, uColor1–uColor4 (Vector3s from hexToRgb), uColorIntensity, uSoftness. Vertex = vertexShader, fragment = displayShader.
Mouse input
let mouseX = 0, mouseY = 0, prevMouseX = 0, prevMouseY = 0, lastMoveTime = 0;
document.addEventListener("mousemove", (e) => {
const rect = gradientCanvas.getBoundingClientRect();
prevMouseX = mouseX; prevMouseY = mouseY;
mouseX = e.clientX - rect.left;
mouseY = rect.height - (e.clientY - rect.top); // flip Y to GL coords
lastMoveTime = performance.now();
fluidMaterial.uniforms.iMouse.value.set(mouseX, mouseY, prevMouseX, prevMouseY);
});
document.addEventListener("mouseleave", () => {
fluidMaterial.uniforms.iMouse.value.set(0, 0, 0, 0);
});
Key detail: the shader treats iMouse.z > 0.0 as "brush active", so zeroing the vector turns the brush off.
Render loop (rAF, ping-pong)
Every frame, in this exact order:
time = performance.now() * 0.001;→ write toiTimeon both materials; writeframeCounttoiFrame.- Idle cutoff: if
performance.now() - lastMoveTime > 100(ms), setiMouseto(0,0,0,0)— the brush only injects while the mouse is actively moving. - Copy every
configvalue back into its uniform (brush, decay, distortion, intensity, softness, and re-runhexToRgbon the four colors) — this makes the config live-tweakable. - Fluid pass:
fluidMaterial.uniforms.iPreviousFrame.value = previousFluidTarget.texture;→renderer.setRenderTarget(currentFluidTarget);→renderer.render(fluidPlane, camera); - Display pass:
displayMaterial.uniforms.iFluid.value = currentFluidTarget.texture;→renderer.setRenderTarget(null);→renderer.render(displayPlane, camera); - Swap
currentFluidTarget↔previousFluidTarget;frameCount++.
Resize
On window.resize: renderer.setSize(w, h), update iResolution on both materials, setSize(w, h) on both render targets, and reset frameCount = 0 (re-seeds the simulation from frame zero).
Shaders (shaders.js)
Vertex shader (shared passthrough):
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
fluidShader — a compact self-advecting fluid sim. State per texel (RGBA): xy = velocity, z = pressure/trail, w = misc. Uniforms as listed above; varying vec2 vUv.
Helpers (globals vec2 ur, U;):
// distance from point p to segment a–b
float ln(vec2 p, vec2 a, vec2 b) {
return length(p-a-(b-a)*clamp(dot(p-a,b-a)/dot(b-a,b-a),0.,1.));
}
// wrap-around samples of the previous frame, in pixel space
vec4 t(vec2 v, int a, int b) { return texture2D(iPreviousFrame, fract((v+vec2(float(a),float(b)))/ur)); }
vec4 t(vec2 v) { return texture2D(iPreviousFrame, fract(v/ur)); }
// triangle area via Heron's formula
float area(vec2 a, vec2 b, vec2 c) {
float A = length(b-c), B = length(c-a), C = length(a-b), s = 0.5*(A+B+C);
return sqrt(s*(s-A)*(s-B)*(s-C));
}
main(), with U = vUv * iResolution; ur = iResolution.xy;:
- Seed frame —
if (iFrame < 1):float w = 0.5+sin(0.2*U.x)*0.5; float q = length(U-0.5*ur); gl_FragColor = vec4(0.1*exp(-0.001*q*q), 0, 0, w);(a soft central x-velocity bump so the gradient isn't static at load). - Semi-Lagrangian advection — take
v = Uand four diagonal probesA=v+(1,1), B=v+(1,-1), C=v+(-1,1), D=v+(-1,-1); iterate 8 times: subtractt(p).xyfrom each of the five points (walk each point back along the velocity field). - Diffusion — sample
me = t(v)and its 4-neighborhoodn,e,s,w(offsets ±1);me = mix(t(v), 0.25*(n+e+s+w), vec4(0.15, 0.15, 0.95, 0.));— velocity blends 15% toward the neighbor average, pressure 95%. - Divergence → pressure:
me.z -= 0.01*((area(A,B,C)+area(B,C,D)) - 4.); - Pressure gradient → velocity:
vec4 pr = vec4(e.z, w.z, n.z, s.z); me.xy += 100.*vec2(pr.x-pr.y, pr.z-pr.w)/ur; - Decay:
me.xy *= uFluidDecay;me.z *= uTrailLength; - Mouse brush — only
if (iMouse.z > 0.0): mouseVel = iMouse.xy - iMouse.zw;velMagnitude = length(mouseVel);q = ln(U, iMouse.xy, iMouse.zw);(distance to the cursor's segment this frame)- clamp the injected vector:
if (l > 0.0) m = min(l, 10.0) * m / l; float brushSizeFactor = 1e-4 / uBrushSize; float strengthFactor = 0.03 * uBrushStrength;float falloff = pow(exp(-brushSizeFactor*q*q*q), 0.5);(cubic-exponential falloff → soft wide brush)me.xyw += strengthFactor * falloff * vec3(m, 10.);- Stationary-cursor damping —
if (velMagnitude < 2.0):float influence = exp(-length(U - iMouse.xy) * 0.01); float cursorDecay = mix(1.0, uStopDecay, influence); me.xy *= cursorDecay; me.z *= cursorDecay;(fluid calms down under a resting cursor instead of buzzing). gl_FragColor = clamp(me, -0.4, 0.4);— hard-clamp keeps the sim stable.
displayShader — the visible gradient, warped by the fluid:
void main() {
vec2 fragCoord = vUv * iResolution;
vec4 fluid = texture2D(iFluid, vUv);
vec2 fluidVel = fluid.xy;
float mr = min(iResolution.x, iResolution.y);
vec2 uv = (fragCoord * 2.0 - iResolution.xy) / mr; // centered, aspect-corrected
uv += fluidVel * (0.5 * uDistortionAmount); // THE hook: fluid warps the gradient
float d = -iTime * 0.5;
float a = 0.0;
for (float i = 0.0; i < 8.0; ++i) { // iterative trig feedback → organic flow
a += cos(i - d - a * uv.x);
d += sin(uv.y * i + a);
}
d += iTime * 0.5;
float mixer1 = cos(uv.x * d) * 0.5 + 0.5;
float mixer2 = cos(uv.y * a) * 0.5 + 0.5;
float mixer3 = sin(d + a) * 0.5 + 0.5;
float smoothAmount = clamp(uSoftness * 0.1, 0.0, 0.9);
mixer1 = mix(mixer1, 0.5, smoothAmount); // softness pulls bands toward mid-blends
mixer2 = mix(mixer2, 0.5, smoothAmount);
mixer3 = mix(mixer3, 0.5, smoothAmount);
vec3 col = mix(uColor1, uColor2, mixer1);
col = mix(col, uColor3, mixer2);
col = mix(col, uColor4, mixer3 * 0.4); // color4 is only a 40% accent
col *= uColorIntensity;
gl_FragColor = vec4(col, 1.0);
}
Assets / images
- 1 logo image, roughly square (~1:1) — a white abstract logo mark / wordmark meant to sit centered over the gradient (it renders at 25% of the viewport width). Transparent or near-black background; no real brand.
Behavior notes
- The gradient animates on its own from frame one (the
iTime-driven trig loop flows continuously); the mouse only *adds* fluid energy on top. With no interaction it settles into a slow ambient drift. - Mouse input stops injecting after 100ms without movement, and the fluid decays (
fluidDecay 0.98/trailLength 0.8per frame), so trails linger ~a second then dissolve. - Desktop-first: the effect is mouse-driven; on touch it just plays the ambient gradient.
FloatTyperender targets make it GPU-heavy — no pixel-ratio tricks in the original. - Resizing rebuilds nothing; it resizes the targets and re-seeds the sim (
frameCount = 0). - No GSAP, no scroll behavior, no reduced-motion branch.