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';
/**
* Wave — a gradient field that flows. One fragment shader over two
* triangles. No three.js, no gradient library, no stack of blurred divs.
*
* The plane is bent first and colour is sampled last, off coordinates that are themselves in motion. Two fields — a slow noise-driven turn and a travelling fold — crossfade under Blend.
*/
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;
// The gradient's own stops, exactly as authored — colour and position both.
uniform vec3 u_colors[6];
uniform float u_stops[6];
uniform vec3 u_background;
// Always present, slider or not: the noise field below is seeded from it.
uniform float u_seed;
// 1.0 closes the animation into a seamless loop of u_loopDur seconds.
uniform float u_loop;
uniform float u_loopDur;
uniform float u_speed;
uniform float u_blend;
uniform float u_amplitude;
uniform float u_softness;
uniform float u_freqX;
uniform float u_freqY;
uniform float u_angle;
uniform float u_lift;
uniform float u_grain;
#define TAU 6.2831853
#define PI 3.1415926
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. Seeded, so the same slider values on a different
// seed are a different field rather than the same picture moved along.
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 phase offset that is stable per seed. Two shaders on the same seed should
// not both start from the same point in their palette.
float seedPhase(float k) {
return fract(sin(u_seed * k) * 437.5853);
}
/* -- the two clocks -------------------------------------------------------- */
// One term of the animation, counted in turns: multiply by TAU for radians, or
// hand it straight to fract for a 0–1 sawtooth.
//
// Free-running it is just rate x time. In loop mode it instead advances by a
// *whole number* of turns over u_loopDur — the number nearest to what the free
// clock would have covered, so the motion keeps its character — which is what
// lets it arrive back exactly where it started. Every time term in every
// shader here goes through this or through drift(), and that is the entire
// reason a recorded loop is seamless rather than cross-faded.
float turns(float rate) {
float n = floor(abs(rate) * u_loopDur + 0.5);
n = max(n, step(1e-4, abs(rate))) * sign(rate);
return mix(u_time * rate, n * fract(u_time / u_loopDur), u_loop);
}
// The same idea for walking through the noise field, where there is no period
// to round to: free-running it walks a straight line, and in loop mode it
// walks a circle of the same length, which returns to its own start.
vec2 drift(float rate) {
float ph = fract(u_time / u_loopDur);
float r = rate * u_loopDur / TAU;
return mix(vec2(u_time * rate, 0.0), vec2(r * cos(TAU * ph), r * sin(TAU * ph)), u_loop);
}
/* -- colour ---------------------------------------------------------------- */
// 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;
}
// The ramp read off a coordinate that runs past its ends — folded back on
// itself rather than wrapped. A ramp here walks a path, one end to the other,
// so fract() would butt the last colour hard against the first every cycle.
// Folding keeps every seam between two colours that were already neighbours.
vec3 rampFold(float t) {
float f = fract(t * 0.5) * 2.0;
return ramp(1.0 - abs(f - 1.0));
}
/* -- resolution ------------------------------------------------------------ */
// How much detail is worth drawing, from the number of repeats packed into one
// pixel: 1 where the pattern is comfortably wider than a pixel, 0 where it has
// collapsed below one. Everything modulated by this fades to flat instead of
// aliasing — WebGL 1 has no fwidth without an extension, so each shader works
// its own repeats-per-pixel out analytically and passes it in.
float sharpness(float perPixel) {
return smoothstep(0.5, 0.06, perPixel);
}
/* -- finish ---------------------------------------------------------------- */
// 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.
vec3 grain(vec3 col, float amount) {
if (amount <= 0.0) return col;
float gt = floor(mix(u_time, fract(u_time / u_loopDur) * u_loopDur, u_loop) * 24.0);
float g = fract(sin(dot(gl_FragCoord.xy + gt, vec2(12.9898, 78.233))) * 43758.5453);
return col + (g - 0.5) * amount;
}
// 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 = seedPhase(7.19) * 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);
}
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
float ratio = u_resolution.x / u_resolution.y;
// Two terms on the clock, each closing on its own whole number of cycles in
// loop mode, plus a drift through the noise field that walks a circle there.
vec2 tw = TAU * vec2(turns(u_speed / TAU), turns(u_speed * 0.7 / TAU));
vec2 dr = drift(u_speed * 0.1);
// Field A — the whole frame turning, slowly, wherever the noise drifts.
vec2 a = uv - 0.5;
float turn = noise(vec2(dr.x, a.x * a.y + dr.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(seedPhase(5.63) * 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);
gl_FragColor = vec4(grain(col, u_grain), 1.0);
}
`;
const PARAMS = {
colors: ['#4973f3', '#7fa0f0', '#b391ed', '#f599c5', '#fbd06a'],
stops: [0, 0.2, 0.42, 0.68, 1],
background: '#08080a',
// One uniform each: `values.speed` arrives as `u_speed`.
values: {
speed: 0.8,
blend: 0.5,
amplitude: 2,
softness: 0.8,
freqX: 1.1,
freqY: 5.4,
angle: 118,
seed: 61,
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('[shader] failed to compile:', gl.getShaderInfoLog(sh));
gl.deleteShader(sh);
return null;
}
return sh;
}
export default function WaveShader({ 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('[shader] 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');
// One uniform per parameter, by name. A location the shader doesn't have comes
// back null, and uniform1f on null is a documented no-op.
for (const [key, value] of Object.entries(PARAMS.values)) {
gl.uniform1f(u('u_' + key), value);
}
const bg = hexToRgb(PARAMS.background);
gl.uniform3f(u('u_background'), bg[0], bg[1], bg[2]);
const ramp = padRamp();
gl.uniform3fv(u('u_colors[0]'), ramp.colors);
gl.uniform1fv(u('u_stops[0]'), ramp.stops);
// 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);
// 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>
);
}