Wave· Indigo Bloom

Export

Take Wave with you — as code, as a still, or as a seamless loop.

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>
  );
}

How it works

Ten visualisations, one recipe. A fragment shader over two triangles — a few kilobytes, no geometry, no library.

Every one of these is the same three moves in a different order: bend the coordinate, cut it up, read colour last. That ordering is the whole effect. Animating colours instead, or translating a stack of blurred divs, gives you something that slides. Moving the coordinates the colour is sampled from gives you something that flows, and that is the part CSS cannot do.

  1. 1

    Bend the plane

    Each visualisation bends it differently. Wave folds it with a travelling sine and turns it with gradient noise. Rail divides by the distance down the frame, which is what turns a fan of converging lines into parallel ones — and dividing the other way gives depth. Aurora stands a bright hem up into the dark; Fog stacks fractal noise, warps it through itself and layers it a few slices deep for the volume.

  2. 2

    Cut it up

    A fract() on the bent coordinate: Rail cuts it into strokes butted straight against each other, with no seam and nothing between one and the next. Halftone cuts it into cells instead, and Cascade quantises a diagonal fan onto a square grid, which is exactly how a staircase edge happens. Fogis the one that doesn't cut at all — it smooths, so there is no edge in it anywhere.

  3. 3

    Read the ramp last

    Colour is sampled off the bent coordinate, so it travels with the shape instead of over it. Where the coordinate runs past the ends of the ramp it is folded back rather than wrapped, which keeps every seam between two colours that were already neighbours. Halftone is the exception that proves it: there, the field is read once per cell rather than per pixel, so a whole mark takes one size and you read the field off the marks' areas — which is what a printing screen actually does.

  4. 4

    Rake it round a point

    Prism works in polar coordinates, mirrored into one quadrant: a cosine of the angle cuts the fan into wedges, an exponential of the radius fades them, and the ramp is read straight off the wedge phase so every blade is a different colour. The frame is then sliced into upright facets that each sample the fan a little offset from the last, with a bright hairline down every seam.

  5. 5

    Fade the detail before it aliases

    Where the detail converges below a pixel — the far end of Rail, the centre of Prism, a fine Halftone screen — the modulation is faded to flat, or to the ink coverage it averages out to. WebGL 1 has no fwidth without an extension, so each shader works out its own repeats-per-pixel from the same division it used for the geometry. Skip this and the vanishing point turns to moire.

  6. 6

    Grain last of all

    Grain goes on after everything: it reads as film, and it dithers away the banding a smooth ramp shows at 4K.

You are looking at Wave. 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.

Rail — Lit strokes running to a vanishing point.

Fog — Banks of coloured cloud, lit from within.

Halftone — A field printed as marks that grow with it.

Prism — Light fanned from a centre, cut across facets.

Cascade — Overlapping shards with staircase edges.

Aurora — Curtains of light drifting across a dark sky.

Paper — Blobs of ink morphing on a risograph sheet.

Morph — A dot screen over islands of ink that keep changing shape.

ASCII — A gradient with patches of it rewritten as code.

Grain — Colour fields folding across a sheet of grainy paper.

Ramps that work. A palette reads well here when it walks a path — hue and lightness both moving one way — because every shader butts the two ends of the ramp against each other somewhere. Ramps that double back on themselves turn to mud at that seam. Every ramp in the shared library is built that way, and the same one drops under any visualisation.

Seamless loops.None of this is naturally periodic, so a recording can't simply cut. For an export, every time term goes through one of two primitives: one advances by a whole number of turns over the chosen duration, the other walks a circle through the noise field instead of a straight line. Both land exactly where they started, so the last frame runs into the first with nothing to cross-fade — and because every shader here is built out of those two, the recorder works on all of them without knowing anything about any of them.