All guides

WebGL Shader Backgrounds in React, Without three.js

By Hizmedia · · 7 min read

The most striking hero backgrounds on the web are fragment shaders: a small program the GPU runs for every pixel, every frame. You don't need three.js or a 3D scene for one. Plain WebGL is about forty lines of setup, adds nothing to your bundle, and gives you complete control. This guide covers the approach behind MotionKit's Liquid Shader, from the full-screen triangle to the details that keep it fast and readable.

Why plain WebGL

A background shader needs a canvas, one shader program and a shape that covers the screen. three.js is excellent for 3D scenes, but for this it adds a large dependency to do what a few WebGL calls already do. Writing it directly keeps the component self-contained, which matters when it's copied into someone else's project.

One triangle that covers the screen

The usual trick is a single oversized triangle rather than two triangles making a rectangle. The parts that fall outside the screen are clipped for free, and there's no diagonal seam where two triangles meet.

example.tsx
const gl = canvas.getContext("webgl", { antialias: false });
// compile and link the vertex and fragment shaders, then:
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const position = gl.getAttribLocation(program, "p");
gl.enableVertexAttribArray(position);
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);

From then on, drawing a frame is setting a few uniforms and calling gl.drawArrays(gl.TRIANGLES, 0, 3).

The liquid look: domain warping

Fractal noise on its own looks like clouds. The fluid, folding look comes from domain warping: using noise to decide where to sample more noise, then doing it again. Each layer bends the one beneath it, which produces the tendrils of ink spreading through water.

example.tsx
vec2 q = vec2(fbm(p + t), fbm(p + vec2(5.2, 1.3) - t * 0.8));
vec2 w = vec2(fbm(p + 4.0 * q + vec2(1.7, 9.2)), fbm(p + 4.0 * q + vec2(8.3, 2.8)));
float f = fbm(p + 4.0 * w);
vec3 col = mix(background, colorA, smoothstep(0.15, 0.85, f));

Mix your brand colours with the noise values, darken the edges with a vignette, and add a touch of per-frame grain so gradients don't band.

Uniforms: time, size and the pointer

Uniforms are how JavaScript talks to the shader. Three are enough: the canvas size, so the pattern keeps its shape at any aspect ratio; elapsed time, which drives the flow; and the pointer position with a strength that eases in and out, so the liquid can swirl around the cursor and settle when it leaves.

Accumulate time yourself rather than passing performance.now() directly. Then a speed setting is just a multiplier, and pausing off screen doesn't make the animation jump when it resumes.

Keep it fast

  • Render below full resolution. The shapes are soft, so about 60% of the device pixel ratio looks identical and costs far less.
  • Stop the loop when the canvas leaves the screen, using an IntersectionObserver.
  • Resize with a ResizeObserver on the canvas, not a window listener, so it also works inside cards and panels.
  • On unmount, cancel the frame, disconnect the observers and call loseContext(), because browsers only allow a limited number of live WebGL contexts.

Readable text and reduced motion

A background is only good if the heading on top of it can be read. Darken the middle with the vignette, then measure contrast against the brightest pixels behind your text, not the average. With reduced motion on, render a single still frame. If WebGL isn't available, keep a CSS gradient in the same colours behind the canvas so the area is never blank.