Skip to main content
Shader backgrounds in React without Three.js

Shader backgrounds in React without Three.js

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.

The whole pipeline is a rectangle

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.

The fragment shader is the component

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.

Compile once, fail loudly

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.

The part Three.js won't do for you: when to render

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:

  1. The user hasn't asked for reduced motion.
  2. The canvas is on screen.
  3. The tab is visible.

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.

Resolution: cap the DPR

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.

Context loss is normal, not an error

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.

The React wrapper is thin

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.

What you give up

Being honest about the trade:

  • No scene graph. If your background needs actual 3D objects, lighting, or a camera, use Three.js or react-three-fiber. This approach is for 2D fields: gradients, streaks, particles computed in the shader, dithers, noise.
  • No shader hot-reload or material system. You edit a template string.
  • You write the boilerplate. About 60 lines of it, once. The code in this post is that boilerplate.

For the backgrounds most landing pages actually want, none of that matters, and you ship a fifth of the JavaScript.

Try it

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.json

If you build something with the pattern, I'd like to see it :)