# WebGL Shader Background in Next.js Without three.js

> You don't need three.js for a shader background. Create the canvas inside useEffect, run the animation loop only while it's on screen and the tab is visible, draw one still frame for Reduce Motion, cap devicePixelRatio, and call loseContext in cleanup. That cleanup took the Too many active WebGL contexts warning from 19 to 0 over 40 remounts.

- URL: https://qodesign.dev/blog/webgl-shader-background-nextjs-react
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-21
- Updated: 2026-10-21
- Category: Animation
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

A shader background takes one canvas, two tiny shaders and about 150 lines of React. You don't need three.js for it. The shader itself takes five minutes. The part tutorials skip is the plumbing around it. Stop the loop when the canvas is off screen or the tab is hidden, respect Reduce Motion, cap the pixel ratio so a 3x phone doesn't render 9 times the pixels, and release the WebGL context on unmount, because browsers only allow a handful at once.

I build WebGL pieces for qodesign, like the [Liquid Metal Blob](/components/liquid-metal), a chrome metaball shader in raw WebGL. For this post I wrote a smaller full-screen background from scratch and measured it in a fresh Next.js 16.4.2 app with React 19.3.1 and Tailwind CSS 4.3.3. Both files below went through strict `tsc` and `next build`. Then headless Chromium measured them at 1280 px and 390 px, in production and in dev mode.

## The component

`components/shader-background.tsx`:

```tsx
"use client";

import { useEffect, useRef } from "react";

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

const FRAGMENT = `
precision mediump float;
uniform vec2 u_res;
uniform float u_time;
void main() {
  vec2 uv = gl_FragCoord.xy / u_res;
  float t = u_time * 0.25;
  float w = sin(uv.x * 6.0 + t) + sin(uv.y * 5.0 - t * 1.3) + sin((uv.x + uv.y) * 4.0 + t * 0.7);
  vec3 a = vec3(0.05, 0.05, 0.08);
  vec3 b = vec3(0.64, 0.90, 0.21);
  gl_FragColor = vec4(mix(a, b, 0.5 + 0.5 * sin(w)) * 0.6, 1.0);
}
`;

function compile(gl: WebGLRenderingContext, type: number, source: string) {
  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(gl.getShaderInfoLog(shader));
    gl.deleteShader(shader);
    return null;
  }
  return shader;
}

export function ShaderBackground({ maxDpr = 1.5 }: { maxDpr?: number }) {
  const hostRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const host = hostRef.current;
    if (!host) return;

    // A fresh canvas per mount, so a lost context is never reused.
    const canvas = document.createElement("canvas");
    canvas.className = "absolute inset-0 h-full w-full";
    host.appendChild(canvas);

    const gl = canvas.getContext("webgl", { antialias: false, powerPreference: "low-power" });
    if (!gl) {
      canvas.remove();
      return; // the CSS gradient on the host stays as the fallback
    }

    const vs = compile(gl, gl.VERTEX_SHADER, VERTEX);
    const fs = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT);
    const program = gl.createProgram();
    if (!vs || !fs || !program) {
      canvas.remove();
      return;
    }
    gl.attachShader(program, vs);
    gl.attachShader(program, fs);
    gl.linkProgram(program);
    gl.useProgram(program);

    // one triangle that covers the whole screen
    const buffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
    const pos = gl.getAttribLocation(program, "a_pos");
    gl.enableVertexAttribArray(pos);
    gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, 0, 0);

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

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");
    let onScreen = true;
    let frame = 0;
    const start = performance.now();

    const draw = (now: number) => {
      gl.uniform1f(uTime, (now - start) / 1000);
      gl.drawArrays(gl.TRIANGLES, 0, 3);
    };

    const loop = (now: number) => {
      draw(now);
      frame = requestAnimationFrame(loop);
    };

    const update = () => {
      cancelAnimationFrame(frame);
      frame = 0;
      if (onScreen && !document.hidden && !reduced.matches) {
        frame = requestAnimationFrame(loop);
      } else {
        draw(start + 4000); // one still frame
      }
    };

    const resize = () => {
      const dpr = Math.min(window.devicePixelRatio || 1, maxDpr);
      canvas.width = Math.max(1, Math.round(host.clientWidth * dpr));
      canvas.height = Math.max(1, Math.round(host.clientHeight * dpr));
      gl.viewport(0, 0, canvas.width, canvas.height);
      gl.uniform2f(uRes, canvas.width, canvas.height);
      update();
    };

    const ro = new ResizeObserver(resize);
    ro.observe(host);
    const io = new IntersectionObserver(([entry]) => {
      onScreen = entry.isIntersecting;
      update();
    });
    io.observe(host);
    document.addEventListener("visibilitychange", update);
    reduced.addEventListener("change", update);
    resize();

    return () => {
      cancelAnimationFrame(frame);
      ro.disconnect();
      io.disconnect();
      document.removeEventListener("visibilitychange", update);
      reduced.removeEventListener("change", update);
      gl.deleteBuffer(buffer);
      gl.deleteProgram(program);
      gl.deleteShader(vs);
      gl.deleteShader(fs);
      gl.getExtension("WEBGL_lose_context")?.loseContext();
      canvas.remove();
    };
  }, [maxDpr]);

  return (
    <div
      ref={hostRef}
      aria-hidden
      className="absolute inset-0 -z-10 overflow-hidden bg-gradient-to-br from-zinc-950 via-zinc-900 to-lime-950"
    />
  );
}
```

And a hero that uses it, `app/shader/page.tsx`:

```tsx
import { ShaderBackground } from "@/components/shader-background";

export default function Page() {
  return (
    <main>
      <section className="relative isolate flex h-dvh items-center justify-center">
        <ShaderBackground />
        <h1 className="text-5xl font-semibold tracking-tight">Ship the hero.</h1>
      </section>
      <section className="h-[150vh] p-8">More content below the hero.</section>
    </main>
  );
}
```

The host div carries a Tailwind gradient. If WebGL isn't available or a shader fails to compile, the canvas is removed and that gradient is what people see. The `isolate` class on the section keeps the `-z-10` background inside the hero instead of behind the whole page.

## What I measured

I counted `requestAnimationFrame` calls per second in headless Chromium. It renders WebGL in software, so treat the frame rates as a floor. A phone GPU has an easier time with this shader.

- **On screen:** 60 frames per second.
- **Scrolled out of view:** 0. The IntersectionObserver stopped the loop, and it went back to 59 when I scrolled up.
- **Hidden tab:** 0. Headless Chromium can't really hide a tab, so I faked `document.hidden` and fired `visibilitychange`, which is what the handler listens to. It came back to 59.5 afterwards.
- **Reduce Motion on:** 0, with one still frame drawn. A screenshot showed the shader's colours, not the fallback gradient, so people still see the background. When I switched Reduce Motion off while the page was open, the loop restarted at 60 without a reload.
- **Pixel ratio:** at a device pixel ratio of 3 the canvas was 1920 by 1200 for a 1280 by 800 hero. The 1.5 cap kept it at 2.25 times the CSS pixels instead of 9.
- **Phone width:** at 390 px the canvas resized to 390 by 844 and nothing scrolled sideways.

## The context limit nobody mentions

Browsers cap how many WebGL contexts a page can have alive. Each new one past the cap kills the oldest. Most tutorials put the canvas in JSX with a ref, start the loop in an effect, and only cancel the frame in cleanup. The context stays alive until garbage collection gets around to it.

I remounted each version 40 times in a row, the way a client-side route change or a changing `key` would:

- **The common version** logged "WARNING: Too many active WebGL contexts. Oldest context will be lost." 19 times.
- **The component above** logged it 0 times. Its cleanup deletes the program, buffer and shaders and calls `loseContext()` from the `WEBGL_lose_context` extension.

If a hero with a shader is part of a layout that remounts, or you have several canvases on one page, that cleanup decides whether one of them goes blank.

## Why the canvas is created in the effect

The obvious fix is to keep the canvas in JSX and call `loseContext()` in cleanup. That works in production. In `next dev` it breaks. React Strict Mode runs every effect, then its cleanup, then the effect again on the same DOM node. The first cleanup loses the context. The second run calls `getContext` on the same canvas, gets the lost context back, and draws nothing. In my dev-mode test that version left the hero blank on first load. The version that kept the context alive rendered, and so did the component above.

Creating the canvas inside the effect and removing it in cleanup gives each run its own canvas. You get a working shader in dev and freed contexts in production. The canvas also never exists in the server HTML, so there's nothing to hydrate and no mismatch to worry about.

## Making it your own

- **Colours:** `a` and `b` in the fragment shader are RGB from 0 to 1. `0.64, 0.90, 0.21` is a lime, and the `* 0.6` at the end keeps it dark enough for white text on top.
- **Speed:** change `0.25` in `u_time * 0.25`.
- **Quality:** lower `maxDpr` to 1 for weak devices. With a soft gradient like this one the difference is hard to see.
- **Still frame:** `draw(start + 4000)` picks the moment of the animation shown to Reduce Motion users and to background tabs. Change the 4000 to pick a nicer frame.

If you need lighting, models or post-processing, three.js or React Three Fiber earns its weight. For a background gradient or a single effect, a raw fragment shader keeps the bundle small. The [three.js hero recipe](/react/threejs-hero-section-nextjs) shows the other route.

## A finished shader piece

qodesign's [Liquid Metal Blob](/components/liquid-metal) is a drop of liquid chrome drawn by a fragment shader. Droplets orbit inside it, a third one reaches toward your pointer, and a click splits it in two and merges it back. It's raw WebGL with no 3D library and comes as plain HTML and as a React component with a `maxDpr` prop. The [React setup for it](/react/liquid-metal-shader) is a few lines. For the motion side, [this post on prefers-reduced-motion in React](/blog/prefers-reduced-motion-react) covers the rest of a page. Free pieces with full source are at [/free](/free).

## FAQ

### How do I add a WebGL shader background in Next.js?

Make a client component that creates a canvas in `useEffect`, compiles a vertex and a fragment shader, and draws one triangle that covers the screen every animation frame. Put it in an absolutely positioned div behind your content. You don't need three.js for a single shader.

### Why do I get "Too many active WebGL contexts" in React?

Every mount that calls `getContext("webgl")` creates a new context. Cancelling the animation frame doesn't free it. Call `gl.getExtension("WEBGL_lose_context")?.loseContext()` in the effect cleanup. In my test that took the warning from 19 to 0 over 40 remounts.

### Why is my WebGL canvas blank in development but fine in production?

React Strict Mode runs effects twice in dev. If your cleanup calls `loseContext()` on a canvas that lives in JSX, the second run gets the lost context back from the same element and draws nothing. Create the canvas inside the effect and remove it in cleanup so each run gets a new one.

### How do I pause a shader when it's off screen?

Watch the canvas with an IntersectionObserver and listen for `visibilitychange`. Only run the `requestAnimationFrame` loop while the canvas is visible and the tab isn't hidden. Draw one still frame otherwise. In my test that dropped the loop from 60 frames per second to 0 when scrolled away.
