
Shader backgrounds in React without Three.js
September 20, 2026
Ashish Gogula

September 20, 2026
Ashish Gogula
Animated WebGL backdrops are everywhere on landing pages now. Most of them ship Three.js to draw a single rectangle. That is around 150 KB of gzipped JavaScript to run one fragment shader.
You don't need it. A full-screen shader background is one quad, one program, and a render loop. Raw WebGL does this in about 200 lines, with zero dependencies, and you get to control the parts that actually matter in production: when it renders, when it stops, and how it dies.
This is how the WebGL backdrops in Planes (opens in new tab) work. The code below is the real Prism component, trimmed.
A background shader doesn't have a scene. There's no camera, no geometry, no lighting. Every pixel is computed from its own coordinate and the time. So the vertex shader is two lines:
attribute vec2 a_pos;
void main() { gl_Position = vec4(a_pos, 0.0, 1.0); }And the "mesh" is four vertices covering clip space:
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
gl.STATIC_DRAW
);
// later, per frame
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);That's the entire geometry story. Everything visual lives in the fragment shader, which gets three uniforms: time, resolution, and whatever knobs you expose as props.
Here's the shape of Prism's shader. Light through glass: soft streaks that bend across the frame, drawn three times with a phase offset per colour channel so the fringes come from dispersion instead of a palette.
precision highp float;
uniform float u_time;
uniform vec2 u_resolution;
uniform float u_dispersion;
float warp(vec2 p, float t, float seed) {
return sin(p.x * 1.3 + t * 0.30 + seed) * 0.35
+ sin(p.x * 2.7 - t * 0.21 + seed * 1.7 + p.y * 1.1) * 0.18
+ sin(p.y * 2.0 + t * 0.17 + seed * 0.6) * 0.22;
}
float streaks(vec2 p, float freq, float t, float seed, float d, float sharp) {
float w = warp(p, t, seed);
float v = sin((p.y + w * 0.35) * freq + d + seed * 3.0);
return pow(max(v, 0.0), sharp) + pow(max(v, 0.0), sharp * 0.25) * 0.10;
}
vec3 family(vec2 p, float freq, float t, float seed, float sharp) {
float d = u_dispersion;
return vec3(
streaks(p, freq, t, seed, d, sharp), // red, shifted one way
streaks(p, freq, t, seed, 0.0, sharp), // green, centred
streaks(p, freq, t, seed, -d, sharp) // blue, shifted the other way
);
}
void main() {
float aspect = u_resolution.x / u_resolution.y;
vec2 c = (gl_FragCoord.xy / u_resolution - 0.5) * vec2(aspect, 1.0);
vec3 col = family(c, 9.0, u_time, 0.0, 6.0) * 0.95
+ family(c * 1.15 + 3.0, 14.0, u_time * 0.8, 2.1, 10.0) * 0.55
+ family(c * 0.85 - 1.0, 22.0, u_time * 1.2, 4.3, 16.0) * 0.30;
vec3 bg = vec3(0.008, 0.012, 0.03);
col = bg + (1.0 - exp(-col * 1.25)); // highlight roll-off
col *= 1.0 - 0.38 * dot(c, c); // vignette
gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
}Two things worth stealing even if you never write a streak shader:
Sum of sines instead of noise. Three sine terms at unrelated frequencies give smooth, organic drift with no texture lookup and no grain. Noise functions are great for clouds and terrain. For "silky background that moves a bit", they're overkill and they shimmer.
Chromatic dispersion by rendering thrice. Evaluate the same field with a small phase offset for R, none for G, and the negative offset for B. You get the blue-on-one-edge, amber-on-the-other fringing of real glass for the cost of three function calls. No colour palette needed, and it reads as physical rather than designed.
Boilerplate, but write it once and keep it honest. Delete the shaders after linking; the program keeps what it needs.
function compileShader(gl: WebGLRenderingContext, type: number, src: string) {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, src);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
function buildProgram(gl: WebGLRenderingContext) {
const vert = compileShader(gl, gl.VERTEX_SHADER, VERT);
const frag = compileShader(gl, gl.FRAGMENT_SHADER, FRAG);
if (!vert || !frag) return null;
const prog = gl.createProgram()!;
gl.attachShader(prog, vert);
gl.attachShader(prog, frag);
gl.linkProgram(prog);
gl.deleteShader(vert);
gl.deleteShader(frag);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(prog));
gl.deleteProgram(prog);
return null;
}
return prog;
}If the program fails to build, return null and render a plain canvas. A background that falls back to a solid colour is fine. A background that throws in useEffect takes the page with it.
This is where hand-rolling pays off. A background shader is the most expensive thing on the page and the least important. It should run only when all three of these are true:
One function decides, and every signal calls it:
const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
let inView = true;
let running = false;
let raf = 0;
function syncLoop() {
const shouldRun = !motionQuery.matches && inView && !document.hidden;
if (shouldRun && !running) {
running = true;
raf = requestAnimationFrame(render);
} else if (!shouldRun && running) {
running = false;
cancelAnimationFrame(raf);
}
// Reduced motion still deserves a picture, just a still one.
if (!running && motionQuery.matches && inView) drawFrame();
}
const io = new IntersectionObserver((entries) => {
inView = entries[0]?.isIntersecting ?? true;
syncLoop();
});
io.observe(canvas);
document.addEventListener("visibilitychange", syncLoop);
motionQuery.addEventListener("change", syncLoop);
syncLoop();Note the last line of syncLoop. Reduced motion doesn't mean "no background". It means draw one frame and stop. Users who turn that setting on still get the design, they just don't get a GPU spinning at 60 Hz while they read.
Scrolling the hero off screen stops the loop. Switching tabs stops the loop. Toggling the OS accessibility setting stops or starts it live. None of that requires a re-render in React, because none of it is React state.
Retina displays will happily ask you for a 5120×2880 framebuffer. For a soft, blurry background that's wasted fill rate.
function resize() {
const dpr = Math.min(devicePixelRatio, 2);
canvas.width = canvas.clientWidth * dpr;
canvas.height = canvas.clientHeight * dpr;
gl.viewport(0, 0, canvas.width, canvas.height);
if (!running) drawFrame(); // keep the still frame sharp after a resize
}
resize();
new ResizeObserver(resize).observe(canvas);Cap at 2. For heavier shaders, cap at 1.5 or even 1 and let the browser upscale. Nobody can tell on a gradient.
Browsers drop WebGL contexts. A laptop switches GPUs, a phone backgrounds the tab for a while, too many canvases exist on one page. If you don't handle it, your background goes black and stays black.
The pattern: put all setup in a function that returns its own cleanup. On loss, run the cleanup. On restore, run setup again.
useEffect(() => {
const canvas = canvasRef.current!;
let cleanup = setup(canvas);
const onLost = (e: Event) => {
e.preventDefault(); // tells the browser you intend to restore
cleanup?.();
cleanup = null;
};
const onRestored = () => {
cleanup?.();
cleanup = setup(canvas);
};
canvas.addEventListener("webglcontextlost", onLost);
canvas.addEventListener("webglcontextrestored", onRestored);
return () => {
canvas.removeEventListener("webglcontextlost", onLost);
canvas.removeEventListener("webglcontextrestored", onRestored);
cleanup?.();
if (!canvas.isConnected) {
canvas.getContext("webgl")?.getExtension("WEBGL_lose_context")?.loseContext();
}
};
}, [speed, dispersion]);Two details that took real bugs to learn:
Delete your GL objects in cleanup. deleteProgram, deleteBuffer, deleteTexture. Contexts are per canvas and the browser limits how many exist at once, usually 8 to 16. Leaking them across route changes in a SPA is how you end up with "the background stopped working after I navigated a few times."
Only force-lose the context when the canvas has actually left the DOM. React Strict Mode runs effects twice in development. If you call loseContext() on every cleanup, the second run finds a dead context on a live canvas and your shader fails to compile with an empty error log. Guard it with canvas.isConnected. This one cost me an afternoon.
All of the above sits in one useEffect. The component itself is a canvas and some props:
export function Prism({ speed = 1, dispersion = 0.35, className }: PrismProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => { /* everything above */ }, [speed, dispersion]);
return <canvas ref={canvasRef} className={cn("block h-full w-full", className)} />;
}Props go into the dependency array, so changing dispersion tears down and rebuilds the program. For a background that's fine. If you need live tweaking at 60 Hz, hold the values in refs and read them in drawFrame instead.
No state, no re-renders, no context providers. Drop it in an absolutely positioned div behind your hero copy and you're done.
Being honest about the trade:
For the backgrounds most landing pages actually want, none of that matters, and you ship a fifth of the JavaScript.
The full Prism component, plus eleven other WebGL backdrops built the same way, are at useplanes.com/components/webgl (opens in new tab).
Every one is a single .tsx file with no dependencies, installed with the shadcn CLI so the source lands in your project and you can edit the shader directly.
They're part of Planes (opens in new tab) Pro, but the pattern in this post is the whole trick, and the code above is enough to build your own. If you'd rather see the CLI flow first, the free components use the same one-file install:
npx shadcn add https://useplanes.com/r/ripple-grid.jsonIf you build something with the pattern, I'd like to see it :)