WebGL Background Effects Without Three.js: Raw Shaders in React
Skip Three.js and write raw GLSL shaders directly in React. Here's how to build animated WebGL background effects with nothing but a canvas element.
Why Skip Three.js for Background Effects
Three.js is fantastic. Nobody's saying it isn't. But you're adding ~600kb (minified, pre-gzip) to your bundle for an animated background — something that runs on 4 vertices and a single fragment shader. That's not a great trade.
Honestly, raw WebGL for background effects is simpler than you'd think once you get past the initial boilerplate. You're not doing scene graphs, camera math, or mesh loading. You've got a fullscreen quad, a clock uniform, and a GLSL program. That's the whole model.
In practice, Three.js abstractions actually get in the way here. You'd write a ShaderMaterial, set up a PlaneGeometry, position a camera exactly right — all to recreate what's two triangles covering the screen. Skip it. The raw API is maybe 80 lines of setup code you write once and reuse everywhere.
Worth noting: this approach pairs perfectly with component styles you'll find when you browse the components. Dropping a shader canvas behind a glassmorphism card? That's a single position: absolute away.
The React Hook That Owns the WebGL Lifecycle
The biggest footgun with WebGL in React is lifecycle mismatch. You initialize a context, start a requestAnimationFrame loop, then the component unmounts. Now you've got a zombie loop incrementing a timer on a dead canvas. Not fun to debug.
The fix is a useEffect that returns a cleanup function — and you need to be disciplined about cancelling the animation frame ID. Here's the hook skeleton:
import { useEffect, useRef } from 'react';
export function useWebGLBackground(fragmentSource: string) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const gl = canvas.getContext('webgl');
if (!gl) return;
// Vertex shader: just a fullscreen quad
const vert = `
attribute vec2 position;
void main() {
gl_Position = vec4(position, 0.0, 1.0);
}
`;
function compile(type: number, src: string) {
const s = gl!.createShader(type)!;
gl!.shaderSource(s, src);
gl!.compileShader(s);
return s;
}
const program = gl.createProgram()!;
gl.attachShader(program, compile(gl.VERTEX_SHADER, vert));
gl.attachShader(program, compile(gl.FRAGMENT_SHADER, fragmentSource));
gl.linkProgram(program);
gl.useProgram(program);
// Fullscreen quad: 2 triangles
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1,-1, 1,-1, -1,1, 1,-1, 1,1, -1,1]),
gl.STATIC_DRAW
);
const posLoc = gl.getAttribLocation(program, 'position');
gl.enableVertexAttribArray(posLoc);
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
const uTime = gl.getUniformLocation(program, 'u_time');
const uRes = gl.getUniformLocation(program, 'u_resolution');
let raf: number;
const start = performance.now();
function resize() {
canvas!.width = canvas!.offsetWidth;
canvas!.height = canvas!.offsetHeight;
gl!.viewport(0, 0, canvas!.width, canvas!.height);
}
resize();
window.addEventListener('resize', resize);
function frame() {
const t = (performance.now() - start) / 1000;
gl!.uniform1f(uTime, t);
gl!.uniform2f(uRes, canvas!.width, canvas!.height);
gl!.drawArrays(gl!.TRIANGLES, 0, 6);
raf = requestAnimationFrame(frame);
}
frame();
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('resize', resize);
gl.deleteProgram(program);
};
}, [fragmentSource]);
return canvasRef;
}That's it. The hook takes a fragment shader string, handles resize, exposes u_time and u_resolution uniforms, and cleans itself up properly. You'd write this once in 2026 and never touch it again.
Writing Your First Fragment Shader (Animated Plasma)
Fragment shaders run once per pixel. That's the mental model. Every pixel on the canvas calls your GLSL function, and you return a color. Add a u_time uniform and suddenly every pixel has a slightly different color on every frame. That's how you get animation.
Here's a plasma wave shader — classic, performant, and genuinely pretty at 1px per pixel:
precision mediump float;
uniform float u_time;
uniform vec2 u_resolution;
void main() {
vec2 uv = (gl_FragCoord.xy / u_resolution) * 2.0 - 1.0;
uv.x *= u_resolution.x / u_resolution.y; // aspect correct
float v = 0.0;
v += sin(uv.x * 4.0 + u_time);
v += sin(uv.y * 4.0 + u_time * 0.7);
v += sin((uv.x + uv.y) * 3.0 + u_time * 1.3);
v += sin(length(uv) * 5.0 - u_time * 2.0);
vec3 col = 0.5 + 0.5 * cos(v + vec3(0.0, 2.094, 4.189));
gl_FragColor = vec4(col, 1.0);
}That vec3(0.0, 2.094, 4.189) is 2π/3 offsets — it's a 120° hue rotation trick that gives you that smooth rainbow cycle without any color lookup table. Quick aside: precision mediump float is a meaningful choice on mobile. highp is noticeably slower on older Android devices.
Drop the shader string into your hook: const ref = useWebGLBackground(plasmaShader) and put <canvas ref={ref} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }} /> behind your content. Done.
Hooking Into Mouse Position and Scroll
A static background shader gets boring fast. The good news: adding interactivity is just passing more uniforms. Mouse position, scroll offset, scroll velocity — all of these become uniform vec2 or uniform float values you set before each draw call.
Extend the hook to accept a uniforms object and sync it each frame. Something like extraUniforms: Record<string, number | [number, number]>. Before your drawArrays call, iterate and call the appropriate gl.uniform* setter. The tricky part is caching uniform locations so you're not calling getUniformLocation every frame — that's a GPU round-trip you don't want at 60fps.
Look, even just u_mouse makes the difference between a background and an experience. A 48px radius influence zone around the cursor, fed into a displacement calculation, costs you basically nothing computationally but feels premium. It's the kind of detail that makes people ask "what library is that?" and the answer is "none."
Performance: What to Watch and What to Ignore
The GPU is not your bottleneck here. Fragment shaders running on a background canvas at 1920×1080 are genuinely cheap compared to what the GPU normally handles. The real trap is JavaScript-side overhead: allocating new Float32Array instances each frame, triggering layout with offsetWidth reads inside the render loop, or (worst) calling getContext more than once.
Cache everything you can before the loop starts. That means uniform locations, buffer references, and canvas dimensions. The resize handler should set a dirty flag, and the render loop should re-read dimensions only when that flag is set — not on every frame.
That said, there's one real concern: the devicePixelRatio. On a Retina display, canvas.offsetWidth is CSS pixels, but WebGL renders at device pixels. If you skip the DPR multiplication, your shader renders at half resolution and gets scaled up — blurry. Multiply both canvas dimensions by window.devicePixelRatio in your resize handler. On a MacBook Pro in 2026 that's typically 2.0, giving you a 2x crisper result.
One more thing — if you're stacking a WebGL canvas behind glassmorphism components, you'll want pointer-events: none on the canvas so clicks fall through to the content layer. Obvious in retrospect, painful to debug when you forget it.
Composing Multiple Shaders as Layers
What if you want a noise base layer, a vignette, and a scanline effect? You could pack all that into one giant fragment shader. Or you could run multiple canvas elements stacked with z-index — but that's expensive. The right answer is shader composition: write modular GLSL functions and concatenate them before compilation.
Build a small composeShader utility that takes an array of GLSL function strings plus a main body that calls them in sequence. Each function outputs a vec4 and you blend them with mix() or overlay math. It stays as one draw call, one program, one GPU context.
This is the same architectural idea behind the gradient generator — layering stops and blend modes. You're just doing it in shader code instead of CSS.
Putting It Together: A Reusable WebGL Background Component
With the hook written, the final component is almost embarrassingly simple:
interface WebGLBackgroundProps {
shader: string;
className?: string;
style?: React.CSSProperties;
}
export function WebGLBackground({ shader, className, style }: WebGLBackgroundProps) {
const ref = useWebGLBackground(shader);
return (
<canvas
ref={ref}
className={className}
style={{
display: 'block',
width: '100%',
height: '100%',
pointerEvents: 'none',
...style,
}}
/>
);
}Wrap it in a relative-positioned container, put your content in a sibling div with position: relative; z-index: 1, and you've got a live shader background under anything. It's the same pattern used in the Empire UI's motion-morphism effects — animated layer underneath, crisp UI components on top.
The component accepts any GLSL string as shader, so you can swap effects at runtime, lazy-load shader strings from a CDN, or even generate them procedurally. That last idea is a rabbit hole worth going down. Write a function that generates plasma parameters based on your brand colors and you'll never use a static gradient background again.
FAQ
WebGL 1 is fine for background effects. You get precision, varying, uniform, and all the trig functions you need. Save WebGL 2 for when you actually need UBOs or transform feedback.
Yes — Safari has supported WebGL 1 since 2011. Just always request the context with a fallback: canvas.getContext('webgl') || canvas.getContext('experimental-webgl') and you'll cover every browser that matters.
Define the shader string outside your component — as a module-level const or imported from a .glsl file — so it's referentially stable. The useEffect dependency on fragmentSource only re-runs when the string reference changes.
Absolutely. Copy a single snoise or fbm implementation directly into your shader string — they're 20-30 lines of pure GLSL math. No npm package needed, no bundle cost, just paste and use.