AETumi — AI-native 3D web platform for Three.js, WebGL and interactive websites
News & Guides

How to Add a WebGL Shader Background to a React Website

September 8, 2026 · AETumi

Key answer: To add a WebGL shader background in React, render a full-bleed <canvas> fixed behind your content, grab it with a ref inside useEffect, compile a fragment shader that draws a full-screen quad, and drive it with a requestAnimationFrame loop that passes time and resolution as uniforms. The three things that make it production-safe are a capped device pixel ratio, a resize handler, and a cleanup function that cancels the loop and loses the GL context on unmount. Respect prefers-reduced-motion by swapping the animation for a static frame.

Table of contents

Why a shader background, and when

A fragment shader runs once per pixel on the GPU, so an animated gradient, flow field, or noise wash costs almost nothing on the main thread — it is far cheaper than animating hundreds of DOM elements. That makes a shader a good fit for a hero backdrop or a section divider that should feel alive without stealing frames from your React tree.

Before vs afterAETumi technical diagram — Before vs afterNaive WebGLProduction WebGLAlways-on loopPause / throttleUncompressed PNGKTX2 / BasisNo fallbackPoster + reduced-motionLeaks memoryDisposes cleanly
Before vs after
Prism Streaks
Prism Streaks — live preview from the AETumi library

This article is a React implementation how-to. If you want the broader survey of techniques and effect types, read WebGL backgrounds and shader effects first, then come back here to wire one into a component. AETumi, an AI-native 3D web platform, ships these same shader backgrounds as drop-in React components if you would rather skip the boilerplate.

Keep scope honest: a shader background is decoration. It should sit behind readable text, never on top of it, and it should degrade to something calm when motion is unwelcome.

The architecture

The pieces are always the same:

The stack, layer by layerAETumi technical diagram — The stack, layer by layerGLSL fragment & vertex shadersFull-screen quad / trianglerAF loop (DPR-capped)Texture compression (KTX2)Reduced-motion static poster
The stack, layer by layer
Halcyon Gate — Night
Halcyon Gate — Night — live preview from the AETumi library
  • A <canvas> positioned fixed (or absolute in a relative container) filling its area, with content layered above via z-index.
  • A WebGL context and a program built from two shaders: a trivial vertex shader that positions a full-screen triangle, and the fragment shader that does the visual work.
  • Uniforms the shader reads each frame — at minimum u_time and u_resolution.
  • A requestAnimationFrame loop that updates u_time and redraws.
  • Lifecycle glue: resize handling, DPR capping, pausing when offscreen, and teardown on unmount.

In React, all of this lives inside a single useEffect with an empty dependency array so it runs once per mount, and returns a cleanup function.

A minimal fragment shader

A flowing gradient is a good starting point — no textures, no geometry, just coordinates and time.

Fluted Veil
Fluted Veil — live preview from the AETumi library
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;

void main() {
  vec2 uv = gl_FragCoord.xy / u_resolution;
  float wave = 0.5 + 0.5 * sin(uv.x * 3.0 + u_time * 0.4)
                   * cos(uv.y * 3.0 - u_time * 0.3);
  vec3 a = vec3(0.05, 0.02, 0.15);
  vec3 b = vec3(0.55, 0.36, 1.0);
  gl_FragColor = vec4(mix(a, b, wave), 1.0);
}

The vertex shader just passes clip-space positions through:

attribute vec2 a_position;
void main() { gl_Position = vec4(a_position, 0.0, 1.0); }

Wiring it into a React component

The component below is self-contained: it compiles the program, draws a full-screen triangle, runs the loop, caps DPR, handles resize, honors reduced motion, and tears everything down on unmount.

Halcyon Gate
Halcyon Gate — live preview from the AETumi library
import { useEffect, useRef } from "react";

const VERT = `attribute vec2 a_position;
void main(){ gl_Position = vec4(a_position,0.0,1.0); }`;

const FRAG = `precision mediump float;
uniform vec2 u_resolution; uniform float u_time;
void main(){
  vec2 uv = gl_FragCoord.xy / u_resolution;
  float w = 0.5 + 0.5 * sin(uv.x*3.0 + u_time*0.4) * cos(uv.y*3.0 - u_time*0.3);
  gl_FragColor = vec4(mix(vec3(0.05,0.02,0.15), vec3(0.55,0.36,1.0), w), 1.0);
}`;

function compile(gl, type, src) {
  const s = gl.createShader(type);
  gl.shaderSource(s, src);
  gl.compileShader(s);
  if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
    console.error(gl.getShaderInfoLog(s));
  }
  return s;
}

export default function ShaderBackground() {
  const canvasRef = useRef(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    const gl = canvas.getContext("webgl", { antialias: false });
    if (!gl) return; // no WebGL: leave the CSS fallback background visible

    const program = gl.createProgram();
    gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, VERT));
    gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, FRAG));
    gl.linkProgram(program);
    gl.useProgram(program);

    // Full-screen triangle
    const buf = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, buf);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 3,-1, -1,3]), gl.STATIC_DRAW);
    const loc = gl.getAttribLocation(program, "a_position");
    gl.enableVertexAttribArray(loc);
    gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);

    const uRes = gl.getUniformLocation(program, "u_resolution");
    const uTime = gl.getUniformLocation(program, "u_time");

    const resize = () => {
      const dpr = Math.min(window.devicePixelRatio || 1, 2); // cap DPR
      const w = Math.floor(canvas.clientWidth * dpr);
      const h = Math.floor(canvas.clientHeight * dpr);
      if (canvas.width !== w || canvas.height !== h) {
        canvas.width = w; canvas.height = h;
        gl.viewport(0, 0, w, h);
      }
    };
    resize();
    window.addEventListener("resize", resize);

    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    let raf = 0;
    let running = true;
    const start = performance.now();

    const render = (now) => {
      gl.uniform2f(uRes, canvas.width, canvas.height);
      gl.uniform1f(uTime, reduce ? 0 : (now - start) / 1000);
      gl.drawArrays(gl.TRIANGLES, 0, 3);
      if (!reduce && running) raf = requestAnimationFrame(render);
    };
    render(start);

    // Pause when the tab is hidden or the canvas scrolls offscreen
    const onVisibility = () => {
      if (document.hidden) { running = false; cancelAnimationFrame(raf); }
      else if (!reduce) { running = true; raf = requestAnimationFrame(render); }
    };
    document.addEventListener("visibilitychange", onVisibility);

    return () => {
      running = false;
      cancelAnimationFrame(raf);
      window.removeEventListener("resize", resize);
      document.removeEventListener("visibilitychange", onVisibility);
      gl.getExtension("WEBGL_lose_context")?.loseContext();
    };
  }, []);

  return (
    <canvas
      ref={canvasRef}
      aria-hidden="true"
      style={{
        position: "fixed", inset: 0, width: "100%", height: "100%",
        zIndex: -1, background: "#0a0417", // static fallback frame
      }}
    />
  );
}

Render it once near the root, then let normal content sit above it. Because the canvas is aria-hidden and behind the flow at zIndex: -1, it never interferes with the readable, indexable HTML. This same structure — one canvas at the root, real DOM layered above — is how the AETumi shader backgrounds are packaged so they slot into an existing layout without disturbing it.

Performance and accessibility

  • Cap the pixel ratio. On a 3x phone display, an uncapped canvas renders nine times the pixels. Math.min(devicePixelRatio, 2) keeps quality high and the GPU cool.
  • Pause when it can't be seen. The visibilitychange handler above stops the loop on a hidden tab. For a canvas that scrolls out of view, an IntersectionObserver toggling running saves the same work.
  • Respect prefers-reduced-motion. When the media query matches, draw a single frame at u_time = 0 and skip the loop entirely — a static poster instead of animation.
  • Keep text as real HTML. The shader is decoration only; headings, copy and links stay in the DOM so screen readers and search engines read them normally.
  • Provide a CSS fallback. The canvas background color renders if WebGL is unavailable or the context is lost, so the section is never blank.

Every one of these safeguards is already wired into the AETumi shader-background components, which is the practical reason to start from a vetted part rather than reassemble the lifecycle each time.

Common mistakes

  • Forgetting cleanup. Without cancelling the rAF loop and removing listeners on unmount, a route change leaves a loop running against a detached canvas — a leak that compounds. Always return a cleanup function from useEffect.
  • Not losing the GL context. Browsers allow a limited number of live WebGL contexts. Call WEBGL_lose_context on teardown so remounts don't exhaust them.
  • Animating during SSR. WebGL only exists in the browser. Access canvasRef.current and window inside useEffect, never during render, or a server framework throws.
  • Uncapped DPR plus antialiasing. Both multiply fill cost. Start with antialias: false and a capped ratio, then add quality only if the frame budget allows.
  • Putting content inside the canvas. Text drawn in WebGL is invisible to search and assistive tech. Layer real DOM above instead.
From idea to productionAETumi technical diagram — From idea to production01Compile shaders02Full-screen quad03rAF render loop04Cap DPR & compress05Dispose on unmount
From idea to production

FAQ

Do I need Three.js for a shader background? No. A single full-screen fragment shader needs only the raw WebGL API, as shown above. Three.js or React Three Fiber earns its weight when you add 3D geometry, lighting, or model loading — not for a flat animated backdrop.

Where do I put the component so it sits behind everything? Render it once high in the tree with position: fixed; inset: 0; z-index: -1. Everything else in normal flow paints above it automatically.

How do I keep it from hurting SEO? The canvas holds no text and is aria-hidden. Keep your real content as server-rendered HTML around it, and indexing is unaffected.

Can I pass mouse or scroll position into the shader? Yes — add another uniform (for example u_mouse) and update it from a React event handler or a ref before each draw, the same way u_time is updated.

Is there a ready-made version I can just install? Yes. AETumi ships shader backgrounds as React components with the DPR cap, resize handling, offscreen pausing, reduced-motion path, and cleanup already in place, so you drop one in rather than porting the boilerplate above. The editable source is included in the Full Stack plan.

Conclusion

A WebGL shader background in React is one canvas, one program, one animation loop, and disciplined lifecycle handling: cap DPR, handle resize, pause when hidden, honor reduced motion, and tear down on unmount. Get those right and you have a GPU-driven backdrop that costs almost nothing on the main thread — the exact contract AETumi holds its shader components to.

If you would rather start from vetted parts than assemble the boilerplate, browse the WebGL hub at aetumi.app for patterns, and reference implementations in github.com/AETumiApp/webgl-react-components. AETumi ships WebGL shader backgrounds as ready React components, with the editable source included in the Full Stack plan — buy once, own for life.

More from the AETumi library

Real, production-ready assets — preview the motion, grab the source.

Browse all WebGL effects →