Shader Studio

Indigo Bloom

Export

Take the gradient 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';

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

How it works

One fragment shader over two triangles. A few kilobytes, no geometry, no library.

The trick is a domain warp: the coordinate plane is bent first, and colour is sampled last — off coordinates that are themselves in motion. Animating the colours instead, or translating a stack of blurred divs, gives you something that slides. Bending the coordinate system gives you something that flows. That difference is the whole effect, and it is why this can't be done in CSS.

  1. 1

    Field A — a slow drift

    Gradient noise, sampled against a slowly advancing clock, decides how far the entire frame is turned at that pixel. Aspect-correcting around the rotation keeps it from shearing on a wide canvas.

  2. 2

    Field B — a travelling fold

    A wave running across the plane is turned into a per-pixel rotation. A smoothstepdecides how wide the band is where the plane is actually turning — that's Softness — and everything outside it stays put. Amplitude is how hard it turns.

  3. 3

    Blend the two

    Blend trades the drift against the fold. Near 0 the frame breathes; near 1 it folds.

  4. 4

    Read the ramp twice

    The gradient is sampled across the warped plane twice— once forward, once reversed, at slightly different angles — then crossed vertically. This is the step people skip, and it's what puts the far ends of the ramp next to each other in the middle of the frame. One read would only ever show the palette in order.

  5. 5

    Lift the mids

    A small non-linear lift keeps the middle of the ramp from going flat. Grain goes on last: it reads as film, and it dithers away the banding a smooth ramp shows at 4K.

Ramps that work. Because the ramp is read twice and its two ends meet mid-frame, a palette reads well here when it walks a path — hue and lightness both moving one way. Ramps that double back on themselves turn to mud at that seam. Every preset in the list is built that way; the first two are the gradients running behind the RatioDS modals.

Seamless loops.The animation is not naturally periodic, so a recording doesn't simply cut. For an export, every time term in the shader is swapped for one that closes on a whole number of cycles over the chosen duration, and the noise drift walks a circle through the field instead of a straight line. The last frame runs into the first exactly, with nothing to cross-fade.