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.
Usage
/* Deepak Kumar E — https://craft.iam-deepak.space */ import { MeshGradient } from "@/components/meshgradient"; <MeshGradient />Customise
0 renders a single still frame and stops the loop.
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.