Three.js Scroll Shader Fluid Gradient Wave — GLSL ShaderMaterial Driven by Scroll
Three.js Scroll Shader Fluid Gradient Wave · Scroll · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<section class="fgw-stage" id="fgwStage">
<div class="fgw-intro-overlay"><p>Scroll ↓ to stir the gradient</p></div>
<canvas id="fgwCanvas"></canvas>
<div class="fgw-hud"><span id="fgwPct">0</span>% amplitude</div>
</section>
<section class="fgw-bottom"><p>A single ShaderMaterial plane, waving entirely on the GPU.</p></section>*{box-sizing:border-box;margin:0;padding:0}
html,body{background:#040308;color:#fff;font-family:system-ui,-apple-system,sans-serif}
.fgw-bottom{min-height:70vh;display:flex;justify-content:center;align-items:center;color:#a8b3d6;font-size:15px;letter-spacing:.08em;text-transform:uppercase;text-align:center;padding:0 24px}
.fgw-stage{height:100vh;position:relative;overflow:hidden;background:#040308}
.fgw-intro-overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;text-align:center;padding:24px;pointer-events:none;z-index:5;color:#eef0ff;font-size:15px;letter-spacing:.08em;text-transform:uppercase;text-shadow:0 2px 16px rgba(0,0,0,.6);transition:opacity .4s ease}
#fgwCanvas{display:block;width:100%;height:100%}
.fgw-hud{position:absolute;left:24px;bottom:24px;font-variant-numeric:tabular-nums;font-size:13px;letter-spacing:.14em;color:#c7d2ff;text-transform:uppercase;opacity:.85}const canvas = document.getElementById('fgwCanvas');
const pctEl = document.getElementById('fgwPct');
const introEl = document.querySelector('.fgw-intro-overlay');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const vertexShader = [
'varying vec2 vUv;',
'void main() {',
' vUv = uv;',
' gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);',
'}',
].join('\n');
const fragmentShader = [
'precision highp float;',
'varying vec2 vUv;',
'uniform float uProgress;',
'uniform float uTime;',
'',
'void main() {',
' vec2 uv = vUv;',
'',
' float speed = 0.35 + uProgress * 0.9;',
' float amp = 0.06 + uProgress * 0.16;',
'',
' float wave1 = sin(uv.x * 6.0 + uTime * speed) * amp;',
' float wave2 = sin((uv.x * 3.0 - uv.y * 2.0) * 4.0 + uTime * speed * 1.4) * amp * 0.6;',
' float wave3 = cos(uv.y * 5.0 - uTime * speed * 0.7) * amp * 0.4;',
'',
' float band = uv.y + wave1 + wave2 + wave3;',
'',
' vec3 colorA = vec3(0.04, 0.07, 0.20);',
' vec3 colorB = vec3(0.10, 0.45, 0.62);',
' vec3 colorC = vec3(0.85, 0.42, 0.66);',
'',
' float hueShift = uProgress * 0.35;',
' vec3 shiftedB = mix(colorB, vec3(0.55, 0.22, 0.78), hueShift);',
' vec3 shiftedC = mix(colorC, vec3(0.98, 0.75, 0.35), hueShift);',
'',
' vec3 color = mix(colorA, shiftedB, smoothstep(0.15, 0.55, band));',
' color = mix(color, shiftedC, smoothstep(0.55, 0.95, band));',
'',
' float sheen = smoothstep(0.48, 0.5, band) * (1.0 - smoothstep(0.5, 0.52, band));',
' color += sheen * 0.5;',
'',
' gl_FragColor = vec4(color, 1.0);',
'}',
].join('\n');
const uniforms = {
uProgress: { value: 0 },
uTime: { value: 0 },
};
const material = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
uniforms,
});
const geometry = new THREE.PlaneGeometry(2, 2);
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
gsap.registerPlugin(ScrollTrigger);
const progress = { t: 0 };
gsap.to(progress, {
t: 1,
ease: 'none',
scrollTrigger: {
trigger: '#fgwStage',
start: 'top top',
end: '+=350%',
scrub: 0.6,
pin: true,
},
});
function resize() {
const w = canvas.clientWidth, h = canvas.clientHeight;
renderer.setSize(w, h, false);
}
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
if (introEl) introEl.style.opacity = (progress.t > 0.03) ? '0' : '1';
uniforms.uProgress.value = progress.t;
uniforms.uTime.value = clock.getElapsedTime();
pctEl.textContent = Math.round(progress.t * 100);
renderer.render(scene, camera);
}
resize();
window.addEventListener('resize', resize);
animate();Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
How to Build a Scroll-Driven Fluid Gradient Wave Shader With Three.js

The Three.js Scroll Shader Fluid Gradient Wave snippet renders a single full-viewport plane with a custom THREE.ShaderMaterial whose fragment shader combines several sine and cosine waves into a smoothly undulating color band, then mixes three colors across that band — and as the visitor scrolls through a pinned section, both the wave's amplitude/speed and the gradient's color mix shift together, driven entirely by a uProgress uniform.
Layered waves, not one sine function
The shader sums three separate wave terms — wave1 (a fast horizontal sine), wave2 (a diagonal sine combining both UV axes), and wave3 (a slower vertical cosine) — each with its own frequency and phase speed, into a single band value derived from uv.y plus all three waves. Layering multiple simple waves like this is what keeps the result reading as organically fluid rather than as one obviously periodic ripple — the same layering principle used in Scroll Canvas Generative Flow Field's procedural vector field, applied here inside a fragment shader instead of a Canvas2D loop.
A three-color gradient mixed by band position
Two chained mix()/smoothstep() pairs blend from a deep base color through a mid tone into a bright accent color, based on where band falls — since band already has the wave terms baked in, the color transitions themselves visibly ripple and shift, rather than sitting on top of a static gradient.
One uniform driving three things at once
uProgress simultaneously scales speed (how fast the waves animate), amp (how pronounced the waves are), and hueShift (how far the mid and accent colors drift toward a secondary palette via their own mix() calls) — so scrolling further doesn't just speed up the waves, it also intensifies them and shifts the whole gradient's mood, all from one float written into the material each frame.
A thin sheen highlight for extra polish
A narrow smoothstep band around band == 0.5 adds a subtle bright sheen line that rides along with the wave crest, a cheap way to suggest a glossy, liquid-like surface without any lighting model or normal calculation.
uTime versus uProgress
Exactly as in Three.js Scroll Shader Ripple Distortion, uTime advances continuously from a THREE.Clock so the surface never fully stops moving, while uProgress is scroll-bound and controls only the wave's intensity and speed multiplier — two uniforms with two distinct jobs.
Customizing it
Swap the three base colors for your own palette, add a fourth wave term at yet another frequency for more visual complexity, or change hueShift's target colors for a completely different mood shift as the user scrolls. Pair it with Three.js Scroll Shader Ripple Distortion for a related uniform-driven GLSL technique using radial rather than banded waves.
Step by step
How to Use
- 1Load all three CDN scriptsAdd three.min.js, gsap.min.js, and ScrollTrigger.min.js from the CDN panel, in that order.
- 2Paste HTML, CSS, and JSAn undulating full-viewport gradient plane renders inside a pinned stage with a live "% amplitude" read-out.
- 3Scroll downThe waves grow faster and more pronounced, and the gradient shifts toward a secondary color palette.
- 4Scroll back upThe waves calm and the color mix reverts to its resting palette exactly in reverse.
- 5Retune the wavesAdjust each wave term’s frequency and phase-speed multiplier in the fragment shader for a different rhythm.
- 6Swap the paletteChange colorA/colorB/colorC (and their hueShift targets) for a completely different mood.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
A single sine wave reads as an obviously mechanical, uniform ripple. Summing three terms at different frequencies, axis combinations, and phase speeds (wave1, wave2, wave3) into one band value produces a result that looks organically fluid, since no single periodic pattern dominates the visible motion.
uProgress is a single float updated every animation frame from a value kept in sync with a scrubbed GSAP ScrollTrigger tween. It scales the waves’ speed and amplitude, and also drives a hueShift value used to blend the gradient’s mid and accent colors toward a secondary palette — so scrolling further makes the surface simultaneously wavier, faster, and more vividly colored.
uTime advances continuously from a THREE.Clock every frame regardless of scroll position, keeping the wave phase animating even if the user stops scrolling. uProgress only reflects scroll position and controls intensity/speed multipliers, not the base animation phase — the two uniforms serve distinct roles.
A narrow smoothstep window centered on band == 0.5 (using two chained smoothstep calls to build a thin band rather than a hard edge) adds a small amount of extra brightness only where the band value passes through that midpoint, producing a bright line that rides along the wave crest with no lighting model or normal-vector calculation needed.
Yes. Click JSX for a React component, Vue for a Vue 3 SFC, Angular for a standalone component, or Tailwind for a React + Tailwind version. Build the renderer, shader material, and GSAP ScrollTrigger inside a mount effect keyed to a canvas ref, and on unmount kill the ScrollTrigger instance, dispose the geometry and material, and call renderer.dispose().