A shader background takes one canvas, two tiny shaders and about 150 lines of React. You don't need three.js for it. The shader itself takes five minutes. The part tutorials skip is the plumbing around it. Stop the loop when the canvas is off screen or the tab is hidden, respect Reduce Motion, cap the pixel ratio so a 3x phone doesn't render 9 times the pixels, and release the WebGL context on unmount, because browsers only allow a handful at once.
I build WebGL pieces for qodesign, like the Liquid Metal Blob, a chrome metaball shader in raw WebGL. For this post I wrote a smaller full-screen background from scratch and measured it in a fresh Next.js 16.4.2 app with React 19.3.1 and Tailwind CSS 4.3.3. Both files below went through strict tsc and next build. Then headless Chromium measured them at 1280 px and 390 px, in production and in dev mode.
The component
components/shader-background.tsx:
"use client";
import { useEffect, useRef } from "react";
const VERTEX = `
attribute vec2 a_pos;
void main() { gl_Position = vec4(a_pos, 0.0, 1.0); }
`;
const FRAGMENT = `
precision mediump float;
uniform vec2 u_res;
uniform float u_time;
void main() {
vec2 uv = gl_FragCoord.xy / u_res;
float t = u_time * 0.25;
float w = sin(uv.x * 6.0 + t) + sin(uv.y * 5.0 - t * 1.3) + sin((uv.x + uv.y) * 4.0 + t * 0.7);
vec3 a = vec3(0.05, 0.05, 0.08);
vec3 b = vec3(0.64, 0.90, 0.21);
gl_FragColor = vec4(mix(a, b, 0.5 + 0.5 * sin(w)) * 0.6, 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);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
export function ShaderBackground({ maxDpr = 1.5 }: { maxDpr?: number }) {
const hostRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
// A fresh canvas per mount, so a lost context is never reused.
const canvas = document.createElement("canvas");
canvas.className = "absolute inset-0 h-full w-full";
host.appendChild(canvas);
const gl = canvas.getContext("webgl", { antialias: false, powerPreference: "low-power" });
if (!gl) {
canvas.remove();
return; // the CSS gradient on the host stays as the fallback
}
const vs = compile(gl, gl.VERTEX_SHADER, VERTEX);
const fs = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT);
const program = gl.createProgram();
if (!vs || !fs || !program) {
canvas.remove();
return;
}
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
gl.useProgram(program);
// one triangle that covers the whole screen
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 pos = gl.getAttribLocation(program, "a_pos");
gl.enableVertexAttribArray(pos);
gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, 0, 0);
const uRes = gl.getUniformLocation(program, "u_res");
const uTime = gl.getUniformLocation(program, "u_time");
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");
let onScreen = true;
let frame = 0;
const start = performance.now();
const draw = (now: number) => {
gl.uniform1f(uTime, (now - start) / 1000);
gl.drawArrays(gl.TRIANGLES, 0, 3);
};
const loop = (now: number) => {
draw(now);
frame = requestAnimationFrame(loop);
};
const update = () => {
cancelAnimationFrame(frame);
frame = 0;
if (onScreen && !document.hidden && !reduced.matches) {
frame = requestAnimationFrame(loop);
} else {
draw(start + 4000); // one still frame
}
};
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, maxDpr);
canvas.width = Math.max(1, Math.round(host.clientWidth * dpr));
canvas.height = Math.max(1, Math.round(host.clientHeight * dpr));
gl.viewport(0, 0, canvas.width, canvas.height);
gl.uniform2f(uRes, canvas.width, canvas.height);
update();
};
const ro = new ResizeObserver(resize);
ro.observe(host);
const io = new IntersectionObserver(([entry]) => {
onScreen = entry.isIntersecting;
update();
});
io.observe(host);
document.addEventListener("visibilitychange", update);
reduced.addEventListener("change", update);
resize();
return () => {
cancelAnimationFrame(frame);
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", update);
reduced.removeEventListener("change", update);
gl.deleteBuffer(buffer);
gl.deleteProgram(program);
gl.deleteShader(vs);
gl.deleteShader(fs);
gl.getExtension("WEBGL_lose_context")?.loseContext();
canvas.remove();
};
}, [maxDpr]);
return (
<div
ref={hostRef}
aria-hidden
className="absolute inset-0 -z-10 overflow-hidden bg-gradient-to-br from-zinc-950 via-zinc-900 to-lime-950"
/>
);
}And a hero that uses it, app/shader/page.tsx:
import { ShaderBackground } from "@/components/shader-background";
export default function Page() {
return (
<main>
<section className="relative isolate flex h-dvh items-center justify-center">
<ShaderBackground />
<h1 className="text-5xl font-semibold tracking-tight">Ship the hero.</h1>
</section>
<section className="h-[150vh] p-8">More content below the hero.</section>
</main>
);
}The host div carries a Tailwind gradient. If WebGL isn't available or a shader fails to compile, the canvas is removed and that gradient is what people see. The isolate class on the section keeps the -z-10 background inside the hero instead of behind the whole page.
