Let's look at how to render custom WebGL fragment shaders inside a React component without destroying your main thread's frame rate.
- What you'll learn:
- Setting up a clean HTML5 Canvas lifecycle inside React hooks.
- Writing a lightweight WebGL context wrapper with type safety.
- Passing dynamic mouse and time uniforms to fragment shaders.
- Managing cleanup and resize handlers to prevent memory leaks.
Initializing the WebGL Context in React
When working with raw WebGL in React, you want to avoid re-instantiating your context on every render. We'll use a useRef to hold the canvas element and a dedicated initialization effect to compile our shaders.
import React, { useEffect, useRef } from 'react';
export function ShaderCanvas() {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const gl = canvas.getContext('webgl');
if (!gl) {
console.error('WebGL not supported');
return;
}
// Set canvas viewport size to match physical pixels
canvas.width = canvas.clientWidth * window.devicePixelRatio;
canvas.height = canvas.clientHeight * window.devicePixelRatio;
gl.viewport(0, 0, canvas.width, canvas.height);
// Context is ready for shader compilation
return () => {
// Clean up WebGL resources if needed
};
}, []);
return <canvas ref={canvasRef} className="w-full h-96 rounded-lg shadow-inner" />;
}
Gotcha: Always multiply canvas client dimensions by
window.devicePixelRatio. If you skip this, your canvas will look blurry on retina displays.
Compiling Shaders and Linking Programs
Writing shaders requires compiling a vertex shader and a fragment shader, then linking them into a usable program. Let's write a small helper function to handle compilation errors gracefully.
function createShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | null {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error('Shader compile error:', gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
export function initShaderProgram(gl: WebGLRenderingContext, vsSource: string, fsSource: string) {
const vertexShader = createShader(gl, gl.VERTEX_SHADER, vsSource);
const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fsSource);
if (!vertexShader || !fragmentShader) return null;
const shaderProgram = gl.createProgram();
if (!shaderProgram) return null;
gl.attachShader(shaderProgram, vertexShader);
gl.attachShader(shaderProgram, fragmentShader);
gl.linkProgram(shaderProgram);
if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
console.error('Unable to initialize the shader program:', gl.getProgramInfoLog(shaderProgram));
return null;
}
return shaderProgram;
}
Drawing a Full-Screen Quad
To run a fragment shader over every pixel, we draw two triangles forming a rectangle that covers the entire viewport. We pass vertex positions via an attribute buffer.
export function setupBuffers(gl: WebGLRenderingContext, program: WebGLProgram) {
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
const positions = new Float32Array([
-1.0, 1.0,
1.0, 1.0,
-1.0, -1.0,
1.0, -1.0,
]);
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
const positionAttributeLocation = gl.getAttribLocation(program, 'aPosition');
gl.enableVertexAttribArray(positionAttributeLocation);
gl.vertexAttribPointer(positionAttributeLocation, 2, gl.FLOAT, false, 0, 0);
}
Animating with Uniforms in a Render Loop
To make our shader interactive and alive, we need a requestAnimationFrame loop that updates time and mouse position uniforms on every tick.
export function useShaderAnimation(
gl: WebGLRenderingContext,
program: WebGLProgram,
uniforms: { timeLoc: WebGLUniformLocation; mouseLoc: WebGLUniformLocation }
) {
let animationFrameId: number;
const startTime = performance.now();
const render = () => {
const currentTime = (performance.now() - startTime) / 1000.0;
gl.useProgram(program);
gl.uniform1f(uniforms.timeLoc, currentTime);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
animationFrameId = requestAnimationFrame(render);
};
animationFrameId = requestAnimationFrame(render);
return () => cancelAnimationFrame(animationFrameId);
}
Speed Note: Avoid allocating new Float32Arrays or objects inside your
requestAnimationFrameloop. Garbage collection pauses will cause noticeable jank in your framerate.
Handling Window Resizes Gracefully
If the user resizes their browser window, our WebGL viewport and canvas buffer sizes need to sync up immediately to prevent distortion.
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const handleResize = () => {
const width = canvas.clientWidth * window.devicePixelRatio;
const height = canvas.clientHeight * window.devicePixelRatio;
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
gl.viewport(0, 0, width, height);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [gl]);
Summary
By managing your WebGL context lifecycle inside React refs and running raw GLSL loops via requestAnimationFrame, you get buttery-smooth creative effects without framework overhead.
- Next Steps:
- Pass texture inputs into your fragment shader to build image distortion effects.
- Explore OES_element_index_uint extensions for more complex geometry.
- Abstract your shader compilation logic into a reusable custom hook.