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
- The architecture
- A minimal fragment shader
- Wiring it into a React component
- Performance and accessibility
- Common mistakes
- FAQ
- Conclusion
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.
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:
- A
<canvas>positionedfixed(orabsolutein a relative container) filling its area, with content layered above viaz-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_timeandu_resolution. - A
requestAnimationFrameloop that updatesu_timeand 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.
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.
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
visibilitychangehandler above stops the loop on a hidden tab. For a canvas that scrolls out of view, anIntersectionObservertogglingrunningsaves the same work. - Respect
prefers-reduced-motion. When the media query matches, draw a single frame atu_time = 0and 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
backgroundcolor 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_contexton teardown so remounts don't exhaust them. - Animating during SSR. WebGL only exists in the browser. Access
canvasRef.currentandwindowinsideuseEffect, never during render, or a server framework throws. - Uncapped DPR plus antialiasing. Both multiply fill cost. Start with
antialias: falseand 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.
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.

