Drop-in and self-contained — no three.js, no gradient library, nothing to install. The component paints a CSS fallback underneath, so a browser without WebGL gets a still gradient rather than a black box.
'use client';
import { useEffect, useRef } from 'react';
/**
* A live wave gradient: one fragment shader over two triangles. No three.js, no
* gradient library, no stack of blurred divs.
*
* The effect is a domain warp — the plane is bent first and colour is sampled
* last, off coordinates that are themselves in motion. That ordering is what
* makes it read as a gradient that flows rather than as shapes that slide.
*/
const VERT = `
attribute vec2 a_pos;
void main() { gl_Position = vec4(a_pos, 0.0, 1.0); }
`;
const FRAG = `
precision highp float;
uniform vec2 u_resolution;
uniform float u_time;
uniform float u_seed;
uniform float u_speed;
uniform float u_angle;
uniform float u_freqX;
uniform float u_freqY;
uniform float u_amplitude;
uniform float u_softness;
uniform float u_blend;
uniform float u_lift;
uniform float u_grain;
// 1.0 closes the animation into a seamless loop of u_loopDur seconds.
uniform float u_loop;
uniform float u_loopDur;
// The gradient's own stops, exactly as authored — colour and position both.
uniform vec3 u_colors[6];
uniform float u_stops[6];
#define TAU 6.2831853
mat2 rot(float a) {
float s = sin(a), c = cos(a);
return mat2(c, -s, s, c);
}
vec2 hash(vec2 p) {
vec2 k1 = vec2(2127.1 + u_seed * 13.37, 81.17 + u_seed * 7.31);
vec2 k2 = vec2(1269.5 + u_seed * 11.13, 283.37 + u_seed * 5.79);
p = vec2(dot(p, k1), dot(p, k2));
return fract(sin(p) * 43758.5453);
}
// Standard gradient noise — the drift that decides how the frame is turning.
float noise(vec2 p) {
vec2 i = floor(p), f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
float n = mix(
mix(dot(-1.0 + 2.0 * hash(i), f),
dot(-1.0 + 2.0 * hash(i + vec2(1.0, 0.0)), f - vec2(1.0, 0.0)), u.x),
mix(dot(-1.0 + 2.0 * hash(i + vec2(0.0, 1.0)), f - vec2(0.0, 1.0)),
dot(-1.0 + 2.0 * hash(i + vec2(1.0, 1.0)), f - vec2(1.0, 1.0)), u.x),
u.y);
return 0.5 + 0.5 * n;
}
// A travelling wave, turned into a per-pixel rotation. The smoothstep is what
// keeps the fold soft: it decides how wide the band is where the plane is
// actually turning, and everything outside it stays put. Two clocks come in,
// not one, so the loop mode can close each term on its own whole number of
// cycles (see main).
vec2 wave(vec2 p, vec2 t) {
vec2 q = rot(radians(u_angle)) * p;
float phase = fract(sin(u_seed * 7.19) * 437.58) * TAU;
float a = u_freqY * q.y - sin(q.x * u_freqX + q.y - t.x + phase);
a += 0.5 * sin(q.x * u_freqX * 2.0 + q.y * 0.5 + t.y);
a = smoothstep(
cos(a) * u_softness,
sin(a) * u_softness + 3.0,
cos(a - u_freqY * q.y) - sin(a - u_freqX * q.x));
a *= u_amplitude;
return cos(a) * p + sin(a) * vec2(-p.y, p.x);
}
// Walks the stops in order: below a stop the mix is a no-op, above the next one
// it has fully landed, so five successive mixes reproduce the gradient. Ramps
// with fewer than six stops arrive padded — the trailing entries repeat the
// last colour, which makes those mixes no-ops too.
vec3 ramp(float t) {
t = clamp(t, 0.0, 1.0);
vec3 c = u_colors[0];
for (int i = 0; i < 5; i++) {
float k = clamp((t - u_stops[i]) / max(u_stops[i + 1] - u_stops[i], 1e-4), 0.0, 1.0);
c = mix(c, u_colors[i + 1], k);
}
return c;
}
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
float ratio = u_resolution.x / u_resolution.y;
// Two clocks. The free-running one just counts up. The looping one replaces
// every time term with something periodic over u_loopDur: the wave's two
// sines get whole numbers of cycles (n1, n2 — rounded off what the free
// clock would have covered, so the motion keeps its character), and the
// drift walks a circle in the noise field instead of a straight line. Both
// return to their starting value at the end of the loop, exactly.
float t = u_time * u_speed;
float ph = fract(u_time / u_loopDur);
float span = u_speed * u_loopDur;
float n1 = max(1.0, floor(span / TAU + 0.5));
float n2 = max(1.0, floor(span * 0.7 / TAU + 0.5));
vec2 tw = mix(vec2(t, t * 0.7), vec2(TAU * n1 * ph, TAU * n2 * ph), u_loop);
float r = (0.1 * span) / TAU;
vec2 drift = mix(vec2(t * 0.1, 0.0), vec2(r * cos(TAU * ph), r * sin(TAU * ph)), u_loop);
// Field A — the whole frame turning, slowly, wherever the noise drifts.
vec2 a = uv - 0.5;
float turn = noise(vec2(drift.x, a.x * a.y + drift.y) + u_seed);
a.y /= ratio;
a *= rot(radians((turn - 0.5) * 720.0 + 180.0));
a.y *= ratio;
// Field B — the same frame, folded by the wave instead.
vec2 b = (gl_FragCoord.xy * 2.0 - u_resolution) / (u_resolution.x + u_resolution.y) * 2.0;
b *= rot(fract(sin(u_seed * 5.63) * 173.29) * TAU);
b = wave(b, tw) * 0.5;
vec2 p = mix(a, b, u_blend);
// The same ramp read twice across the warped plane — once forward, once
// reversed, at slightly different angles — then crossed vertically. Two reads
// is what puts the far ends of the ramp next to each other in the middle of
// the frame; one read would only ever show it in order. Reading colour last,
// off coordinates that are themselves moving, is what makes this flow rather
// than slide.
float t1 = smoothstep(-0.6, 0.6, (p * rot(radians(-5.0))).x);
float t2 = 1.0 - smoothstep(-0.6, 0.6, (p * rot(radians(10.0))).x);
vec3 col = mix(ramp(t1), ramp(t2), smoothstep(0.35, -0.35, p.y));
// Lifts the mids without clipping the ends — keeps it from going flat.
col = mix(col, col * col + 0.5 * sqrt(col), u_lift);
// Grain, stepped to 24 a second so it reads as film rather than as static.
// Also the cheapest fix for the banding a smooth ramp shows at 1920x1080.
if (u_grain > 0.0) {
float gt = mix(floor(u_time * 24.0), floor(ph * u_loopDur * 24.0), u_loop);
float g = fract(sin(dot(gl_FragCoord.xy + gt, vec2(12.9898, 78.233))) * 43758.5453);
col += (g - 0.5) * u_grain;
}
gl_FragColor = vec4(col, 1.0);
}
`;
const PARAMS = {
colors: ['#4973f3', '#7fa0f0', '#b391ed', '#f599c5', '#fbd06a'],
stops: [0, 0.2, 0.42, 0.68, 1],
seed: 61, speed: 0.8, angle: 118,
freqX: 1.1, freqY: 5.4, amplitude: 2,
softness: 0.8, blend: 0.5, lift: 0.25, grain: 0.02,
};
// The still that shows before the first frame lands — or permanently, if the
// context can't be created. Derived from PARAMS so it can't fall out of step.
const FALLBACK = 'linear-gradient(118deg, #4973f3 0%, #7fa0f0 20%, #b391ed 42%, #f599c5 68%, #fbd06a 100%)';
function hexToRgb(hex: string): [number, number, number] {
const n = parseInt(hex.slice(1), 16);
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}
// The shader's ramp is unrolled to six entries. A shorter gradient is padded by
// repeating its last colour at stop 1.0, which makes the extra mixes no-ops.
function padRamp(): { colors: Float32Array; stops: Float32Array } {
const c: number[] = [], s: number[] = [];
for (let i = 0; i < 6; i++) {
c.push(...hexToRgb(PARAMS.colors[Math.min(i, PARAMS.colors.length - 1)]));
s.push(i < PARAMS.stops.length ? PARAMS.stops[i] : 1);
}
return { colors: new Float32Array(c), stops: new Float32Array(s) };
}
function compile(gl: WebGLRenderingContext, type: number, src: string) {
const sh = gl.createShader(type);
if (!sh) return null;
gl.shaderSource(sh, src);
gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
console.error('[wave] shader failed to compile:', gl.getShaderInfoLog(sh));
gl.deleteShader(sh);
return null;
}
return sh;
}
export default function WaveGradient({ paused = false }: { paused?: boolean }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
// The frame loop reads this rather than closing over `paused`, so toggling it
// doesn't tear down and rebuild the whole GL context.
const pausedRef = useRef(paused);
useEffect(() => {
pausedRef.current = paused;
}, [paused]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const isPaused = () => pausedRef.current;
// alpha: the canvas composites over the CSS fallback rather than over black,
// so any moment it hasn't painted shows the gradient underneath instead of a
// black box. preserveDrawingBuffer: a paused instance isn't redrawing, and its
// held frame is otherwise free to be discarded.
const gl = canvas.getContext('webgl', {
antialias: false,
alpha: true,
depth: false,
preserveDrawingBuffer: true,
}) as WebGLRenderingContext | null;
if (!gl) return; // Leaves the CSS fallback showing.
const vs = compile(gl, gl.VERTEX_SHADER, VERT);
const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG);
const program = gl.createProgram();
if (!vs || !fs || !program) return;
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('[wave] program failed to link:', gl.getProgramInfoLog(program));
return;
}
gl.useProgram(program);
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
const loc = gl.getAttribLocation(program, 'a_pos');
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
const u = (name: string) => gl.getUniformLocation(program, name);
const uResolution = u('u_resolution');
const uTime = u('u_time');
gl.uniform1f(u('u_seed'), PARAMS.seed);
gl.uniform1f(u('u_speed'), PARAMS.speed);
gl.uniform1f(u('u_angle'), PARAMS.angle);
gl.uniform1f(u('u_freqX'), PARAMS.freqX);
gl.uniform1f(u('u_freqY'), PARAMS.freqY);
gl.uniform1f(u('u_amplitude'), PARAMS.amplitude);
gl.uniform1f(u('u_softness'), PARAMS.softness);
gl.uniform1f(u('u_blend'), PARAMS.blend);
gl.uniform1f(u('u_lift'), PARAMS.lift);
gl.uniform1f(u('u_grain'), PARAMS.grain);
// Free-running. Set u_loop to 1 and u_loopDur to N for an N-second seamless
// loop instead — every time term in the shader then closes on itself.
gl.uniform1f(u('u_loop'), 0);
gl.uniform1f(u('u_loopDur'), 10);
const ramp = padRamp();
gl.uniform3fv(u('u_colors[0]'), ramp.colors);
gl.uniform1fv(u('u_stops[0]'), ramp.stops);
// The clock the shader is drawn at. Held across pauses and resizes so a redraw
// never jumps the animation.
let clock = 0;
const render = () => {
gl.uniform1f(uTime, clock);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
};
// Capped at 2: past that the extra pixels cost real frames and buy nothing on
// a gradient with no edges in it.
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = Math.max(1, Math.round(canvas.clientWidth * dpr));
const h = Math.max(1, Math.round(canvas.clientHeight * dpr));
if (canvas.width === w && canvas.height === h) return;
canvas.width = w;
canvas.height = h;
gl.viewport(0, 0, w, h);
gl.uniform2f(uResolution, w, h);
// Resizing throws away the buffer's contents, so a paused instance would go
// blank here if it weren't redrawn at its held time.
render();
};
resize();
const observer = new ResizeObserver(resize);
observer.observe(canvas);
const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// Paint immediately, before any of the animation decisions below. An instance
// that mounts paused would otherwise never reach a first draw.
render();
// Wall-clock time minus however long we've spent paused, so an instance that
// sat paused resumes where it left off instead of jumping.
const start = performance.now();
let owed = 0;
let pausedAt: number | null = null;
let frame = 0;
const draw = (now: number) => {
frame = requestAnimationFrame(draw);
if (isPaused()) {
if (pausedAt === null) pausedAt = now;
return;
}
if (pausedAt !== null) {
owed += now - pausedAt;
pausedAt = null;
}
clock = (now - start - owed) / 1000;
render();
};
if (!still) frame = requestAnimationFrame(draw);
// Safari drops contexts on tab churn and memory pressure; without this the
// canvas would simply go black and stay that way.
const onLost = (e: Event) => {
e.preventDefault();
cancelAnimationFrame(frame);
};
canvas.addEventListener('webglcontextlost', onLost);
const teardown = () => {
cancelAnimationFrame(frame);
observer.disconnect();
canvas.removeEventListener('webglcontextlost', onLost);
gl.deleteBuffer(buffer);
gl.deleteProgram(program);
gl.deleteShader(vs);
gl.deleteShader(fs);
gl.getExtension('WEBGL_lose_context')?.loseContext();
};
return teardown;
}, []);
return (
<div
aria-hidden="true"
style={{ position: 'absolute', inset: 0, background: FALLBACK, overflow: 'hidden' }}
>
<canvas ref={canvasRef} style={{ width: '100%', height: '100%', display: 'block' }} />
</div>
);
}