Back to All Articles
reactwebglcanvasanimationperformance

Building High-Performance WebGL Shaders and Custom Canvas Effects in React

Learn how to build high-performance WebGL shaders and custom canvas effects in React without dropping frames.

2026-10-31
Building High-Performance WebGL Shaders and Custom Canvas Effects in React

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.

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

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

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

TSX
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 requestAnimationFrame loop. 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.

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