Skip to content
2D

Mesh Gradient

A soft, slowly moving gradient of three colour fields, drawn in raw WebGL. Written against the WebGL API directly rather than through three.js, because a gradient is one triangle and forty lines of GLSL — reaching for a scene graph would cost 150KB to gain nothing. Includes a dither pass, since the failure mode of every large flat gradient on an 8-bit display is banding.

Loading preview…

Usage

/* Deepak Kumar E — https://craft.iam-deepak.space */ import { MeshGradient } from "@/components/meshgradient"; <MeshGradient />

Customise

1

0 renders a single still frame and stops the loop.

0.035

Raise it if you can see banding on your display. Costs one hash per pixel.

The component

components/craft/2d/MeshGradient.tsx

/* Deepak Kumar E — https://craft.iam-deepak.space */ import { useEffect, useRef } from "react"; /** * An animated mesh gradient, drawn in a fragment shader. * * Raw WebGL rather than three.js on purpose. A gradient is one full-screen * triangle and about forty lines of GLSL; reaching for a scene graph to draw * it costs ~150KB to gain nothing. That is the difference between this * running for free and it being the heaviest thing on your landing page. * * The motion is a sum of three drifting radial fields. Layering cheap fields * beats one expensive noise function here — smooth gradients hide the low * frequency, so nobody can tell it is not simplex noise, and it costs a * handful of instructions per pixel instead of dozens. */ const VERTEX = `attribute vec2 position;void main() { gl_Position = vec4(position, 0.0, 1.0); }`; const FRAGMENT = `precision mediump float; uniform vec2  uResolution;uniform float uTime;uniform vec3  uColorA;uniform vec3  uColorB;uniform vec3  uColorC;uniform float uGrain; /* Cheap hash for the dither below — no texture lookup, no dependency. */float hash(vec2 p) {  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);} /* A soft radial field whose centre drifts on a Lissajous path.   Centres stay in 0–1 space and the ASPECT IS APPLIED TO THE DISTANCE, not   to the coordinate. Scaling uv.x instead leaves the centres in 0–1 while the   coordinate runs to 2.5 on a wide canvas, so the whole right-hand side falls   outside every field and the normalise below drives it to black. */float field(vec2 uv, float aspect, vec2 phase, float speed, float radius) {  vec2 centre = vec2(    0.5 + 0.34 * sin(uTime * speed + phase.x),    0.5 + 0.28 * cos(uTime * speed * 0.83 + phase.y)  );  vec2 delta = (uv - centre) * vec2(aspect, 1.0);  /* Radii are a fraction of the half-diagonal, so one set of numbers holds up     from a phone to an ultrawide. */  float reach = radius * length(vec2(aspect, 1.0)) * 0.5;  return smoothstep(reach, 0.0, length(delta));} void main() {  vec2 uv = gl_FragCoord.xy / uResolution;  float aspect = uResolution.x / uResolution.y;   float a = field(uv, aspect, vec2(0.0, 1.7), 0.28, 1.15);  float b = field(uv, aspect, vec2(2.4, 4.1), 0.21, 1.05);  float c = field(uv, aspect, vec2(5.1, 2.2), 0.34, 0.95);   /* A small constant weight on the third colour keeps this from dividing by     nothing where all three fields are weak — that corner would otherwise     read as a hard black wedge rather than settling to the base colour. */  float base = 0.04;  vec3 colour = uColorA * a + uColorB * b + uColorC * (c + base);  colour /= (a + b + c + base);   /* Banding is the failure mode of every large flat gradient on an 8-bit     display. A sub-LSB of noise breaks the bands up and costs one hash. */  float grain = (hash(gl_FragCoord.xy + fract(uTime)) - 0.5) * uGrain;  gl_FragColor = vec4(colour + grain, 1.0);}`; function compile(gl: WebGLRenderingContext, type: number, source: string) {  const shader = gl.createShader(type);  if (!shader) return null;  gl.shaderSource(shader, source);  gl.compileShader(shader);  return gl.getShaderParameter(shader, gl.COMPILE_STATUS) ? shader : null;} /** `#rrggbb` to the 0–1 triple a shader wants. */function toRgb(hex: string): [number, number, number] {  const match = /^#?([\da-f]{6})$/i.exec(hex.trim());  if (!match) return [0, 0, 0];  const n = parseInt(match[1], 16);  return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];} export interface MeshGradientProps {  colorA?: string;  colorB?: string;  colorC?: string;  /** Multiplier on the drift. 0 renders a single still frame. */  speed?: number;  /** Dither strength. Raise it if you can see banding on your display. */  grain?: number;  /** Force the still frame — for print, screenshots, or a reduced-motion test. */  still?: boolean;  className?: string;} export function MeshGradient({  colorA = "#c3f53c",  colorB = "#1a3d2e",  colorC = "#0a0b0e",  speed = 1,  grain = 0.035,  still = false,  className = "",}: MeshGradientProps) {  const ref = useRef<HTMLCanvasElement>(null);   useEffect(() => {    const canvas = ref.current;    if (!canvas) return;     const gl = canvas.getContext("webgl", {      antialias: false,      alpha: false,      // A gradient is repainted every frame, so preserving the buffer only      // costs memory bandwidth.      preserveDrawingBuffer: false,    });    if (!gl) return;     const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX);    const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT);    if (!vertex || !fragment) return;     const program = gl.createProgram();    if (!program) return;    gl.attachShader(program, vertex);    gl.attachShader(program, fragment);    gl.linkProgram(program);    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return;    gl.useProgram(program);     /* One triangle covering the viewport, not two making a quad: it avoids       the diagonal seam where the GPU shades the shared edge twice. */    const buffer = gl.createBuffer();    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);    gl.bufferData(      gl.ARRAY_BUFFER,      new Float32Array([-1, -1, 3, -1, -1, 3]),      gl.STATIC_DRAW,    );    const position = gl.getAttribLocation(program, "position");    gl.enableVertexAttribArray(position);    gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);     const uniform = (name: string) => gl.getUniformLocation(program, name);    const uResolution = uniform("uResolution");    const uTime = uniform("uTime");    const uGrain = uniform("uGrain");     gl.uniform3fv(uniform("uColorA"), toRgb(colorA));    gl.uniform3fv(uniform("uColorB"), toRgb(colorB));    gl.uniform3fv(uniform("uColorC"), toRgb(colorC));    gl.uniform1f(uGrain, grain);     const reduced =      still ||      (typeof matchMedia === "function" &&        matchMedia("(prefers-reduced-motion: reduce)").matches);     /* The size THIS effect run has uploaded to THIS program.       Deliberately not read back off canvas.width, which is DOM state that       outlives the effect. Uniforms belong to the program, and a new program       is built every time the effect re-runs — on any prop change, and twice       on mount under StrictMode. Comparing against the canvas then short       circuits the very first resize of the new run, because the element       already carries the right dimensions from the previous one, so       uResolution is never uploaded and stays at its (0,0) default. uv       becomes NaN, every colour resolves to black, and only the grain — which       does not read uResolution — still shows. Reloading appears to fix it       because a fresh canvas starts at 300x150; resizing appears to fix it       because the dimensions genuinely change. */    let uploadedWidth = 0;    let uploadedHeight = 0;     const resize = () => {      /* Capped at 2×: a gradient carries no fine detail, so a 3× phone         display would be shading nine times the pixels for no visible gain         and a measurable battery cost. */      const dpr = Math.min(devicePixelRatio || 1, 2);      const width = Math.floor(canvas.clientWidth * dpr);      const height = Math.floor(canvas.clientHeight * dpr);      /* Ignore a zero measurement instead of committing a 0×0 buffer. During         a client-side navigation the effect can run before layout settles,         and a zero-size buffer sticks — the canvas stays blank until         something else happens to resize it. */      if (!width || !height) return;      if (width === uploadedWidth && height === uploadedHeight) return;       uploadedWidth = width;      uploadedHeight = height;      canvas.width = width;      canvas.height = height;      gl.viewport(0, 0, width, height);      gl.uniform2f(uResolution, width, height);    };     let frame = 0;    const start = performance.now();     const draw = (now: number) => {      resize();      gl.uniform1f(uTime, ((now - start) / 1000) * speed);      gl.drawArrays(gl.TRIANGLES, 0, 3);      if (!reduced) frame = requestAnimationFrame(draw);    };     // Still or animated, the first frame is drawn the same way — so reduced    // motion gets a composed gradient, never an empty canvas.    draw(start);     const observer = new ResizeObserver(() => {      if (reduced) draw(performance.now());    });    observer.observe(canvas);     return () => {      cancelAnimationFrame(frame);      observer.disconnect();      gl.deleteProgram(program);      gl.deleteShader(vertex);      gl.deleteShader(fragment);      gl.deleteBuffer(buffer);      /* Deliberately NOT calling WEBGL_lose_context here.         Forcing the context to drop looks like tidy resource management, but         a context lost that way cannot be reacquired on the same canvas — so         the component renders once and then comes back blank on any remount.         React's StrictMode mounts, unmounts and remounts every effect in         development, which means the very first thing you see is the broken         state. Fast Refresh and conditional rendering do the same in         production. The GPU buffer is reclaimed when the canvas is collected;         the deletes above are the part actually worth doing. */    };  }, [colorA, colorB, colorC, speed, grain, still]);   return (    <canvas      ref={ref}      // Decorative: it carries no information a screen reader needs.      aria-hidden      className={`block h-full w-full ${className}`}    />  );} 

One file. Paste it in, delete what you do not need, change what you do. The "use client" directive is stripped above — add it back if you are on the Next.js App Router.

What it costs

Weight

+2.1KB gzipped · no new dependencies

Runs on

GPU

Work happens in a shader. The main thread only issues draw calls, so JavaScript stays free, but it costs GPU time and battery on mobile.

Reduced motion

Draws one composed frame and never schedules another. The gradient is fully formed — the motion is what stops, not the picture.

Accessibility
  • Canvas is aria-hidden; it is decorative by definition.
  • Adds no focusable elements and no tab stops.
  • Check contrast of whatever you lay over it — the gradient moves, so test the worst-case frame, not the first one.
Watch out
  • Needs a WebGL context. Renders nothing at all if WebGL is unavailable, so set a CSS background colour behind it as the fallback.
  • Full-viewport shading costs GPU time proportional to area. Cheap, but not free on a large 4K display.