All components

WebGL Interactive Gradient

WebGL animation component · Published 2026-07-27 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A full-screen animated WebGL gradient built with Three.js and two custom fragment shaders: a fluid-dynamics shader ping-pongs velocity between two float render targets while a display shader warps a flowing multi-color gradient from that field. A requestAnimationFrame loop advances time-based motion, and mousemove events feed cursor position/velocity into the fluid uniform so the gradient ripples and distorts along the cursor's trail in real time.

How it's built

Category3d-webgl
Techthree
Complexitysection
Performance costheavy
Mobile-safedesktop-first

three-js webgl shader fluid-simulation gradient interactive cursor background

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

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-canvas is the WebGL container — JS appends the renderer's <canvas> into it.
  • .hero-logo holds 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 adds bottom: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 to window.innerWidth × window.innerHeight, appended into .gradient-canvas.
  • Two ping-pong render targets (fluidTarget1, fluidTarget2), both window.innerWidth × window.innerHeight with:

``js { minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter, format: THREE.RGBAFormat, type: THREE.FloatType } ` FloatType` is required — the sim stores signed velocities.

  • Track currentFluidTarget / previousFluidTarget refs plus a frameCount integer (starts 0).
  • geometry = new THREE.PlaneGeometry(2, 2); shared by two meshes: fluidPlane (with fluidMaterial) and displayPlane (with displayMaterial). No scene graph needed — render each mesh directly with renderer.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, uColor1uColor4 (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:

  1. time = performance.now() * 0.001; → write to iTime on both materials; write frameCount to iFrame.
  2. Idle cutoff: if performance.now() - lastMoveTime > 100 (ms), set iMouse to (0,0,0,0) — the brush only injects while the mouse is actively moving.
  3. Copy every config value back into its uniform (brush, decay, distortion, intensity, softness, and re-run hexToRgb on the four colors) — this makes the config live-tweakable.
  4. Fluid pass: fluidMaterial.uniforms.iPreviousFrame.value = previousFluidTarget.texture;renderer.setRenderTarget(currentFluidTarget);renderer.render(fluidPlane, camera);
  5. Display pass: displayMaterial.uniforms.iFluid.value = currentFluidTarget.texture;renderer.setRenderTarget(null);renderer.render(displayPlane, camera);
  6. Swap currentFluidTargetpreviousFluidTarget; 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;:

  1. Seed frameif (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).
  2. Semi-Lagrangian advection — take v = U and four diagonal probes A=v+(1,1), B=v+(1,-1), C=v+(-1,1), D=v+(-1,-1); iterate 8 times: subtract t(p).xy from each of the five points (walk each point back along the velocity field).
  3. Diffusion — sample me = t(v) and its 4-neighborhood n,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%.
  4. Divergence → pressure: me.z -= 0.01*((area(A,B,C)+area(B,C,D)) - 4.);
  5. 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;
  6. Decay: me.xy *= uFluidDecay; me.z *= uTrailLength;
  7. Mouse brush — only if (iMouse.z > 0.0):
  8. mouseVel = iMouse.xy - iMouse.zw; velMagnitude = length(mouseVel);
  9. q = ln(U, iMouse.xy, iMouse.zw); (distance to the cursor's segment this frame)
  10. clamp the injected vector: if (l > 0.0) m = min(l, 10.0) * m / l;
  11. float brushSizeFactor = 1e-4 / uBrushSize; float strengthFactor = 0.03 * uBrushStrength;
  12. float falloff = pow(exp(-brushSizeFactor*q*q*q), 0.5); (cubic-exponential falloff → soft wide brush)
  13. me.xyw += strengthFactor * falloff * vec3(m, 10.);
  14. Stationary-cursor dampingif (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).
  15. 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.8 per frame), so trails linger ~a second then dissolve.
  • Desktop-first: the effect is mouse-driven; on touch it just plays the ambient gradient. FloatType render 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.