---
name: shader-background
description: Use when you want "Technical, artistic, exotic" - Uses fragment shaders to generate animated visual backgrounds.
---

# Shader Background

> **Category:** Advanced WebGL / Canvas  -  **Personality:** Technical, artistic, exotic
>
> **Best use:** AI, gaming, experimental brands
>
> **Ratings:** Professional 3/5 - Casual 3/5 - Exotic 5/5 - Visual impact 5/5 - Difficulty 5/5 - Performance cost 5/5

## What it is

A shader background is a `<canvas>` whose **every pixel is coloured by a GPU fragment shader that re-runs each frame**. Instead of images, video, or DOM elements, you hand the GPU a tiny GLSL program plus a few **uniforms** (time, resolution, mouse) and it paints an animated field. Here the field is a **domain-warped fBm noise** that flows like plasma. The output is resolution-independent, never repeats, and weighs almost nothing (no assets). It reads as technical, artistic and exotic, which is why AI tools, games and experimental brands reach for it as a hero backdrop. We draw a single full-screen quad with three.js so the GLSL *is* the whole effect.

## Dependencies / CDN

WebGL is required, so the demo uses **three.js r160 (UMD, global `THREE`)** from a pinned, immutable jsDelivr file — with Subresource Integrity:

```html
<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"
        integrity="sha384-qOkzR5Ke/XkQxuGVJ9hpFEpDlcoLtWwVYhnJf06cLIZa2vaIptSqaubivErzmD5O"
        crossorigin="anonymous"></script>
```

Optional display/body fonts (Syne + JetBrains Mono):

```html
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@700;800&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
```

> `ogl` is a tinier alternative, but `ogl@1.0.11` ships **ES-module source only** (no UMD/global), and its bundled `+esm` build is generated on the fly so it **cannot carry SRI**. three.js's static versioned UMD file is the robust, SRI-able choice. (r160's UMD prints one harmless deprecation warning to the console.)

## HTML

A **contained** rounded stage holds the canvas, a scrim for text contrast, and the hero content:

```html
<div class="sh-stage" id="shStage">
  <canvas class="sh-canvas" id="shCanvas" aria-hidden="true"></canvas>
  <div class="sh-scrim" aria-hidden="true"></div>
  <div class="sh-ui">
    <h2 class="sh-h">Every frame, <em>born from noise.</em></h2>
    <p class="sh-sub">One fragment shader. Infinite, never-repeating motion.</p>
    <button class="sh-primary">Launch the engine</button>
    <!-- + a live HUD reading uTime / uResolution / uMouse -->
  </div>
</div>
```

## CSS

The essentials: clip the canvas inside a rounded stage, give the stage a gradient that **doubles as the WebGL fallback**, and lay a scrim over it so the headline stays legible whatever colours the shader produces.

```css
.sh-stage{
  position:relative; overflow:hidden; border-radius:26px; min-height:644px; isolation:isolate;
  /* this gradient is what shows when WebGL is unavailable (JS adds .sh-is-fallback) */
  background:
    radial-gradient(120% 130% at 80% 10%, rgba(154,130,255,.55), transparent 56%),
    radial-gradient(120% 120% at 14% 94%, rgba(255, 77,157,.45), transparent 55%),
    radial-gradient(120% 120% at 56% 64%, rgba( 61,240,226,.30), transparent 60%),
    linear-gradient(160deg,#070a16,#04050c);
}
.sh-canvas{position:absolute; inset:0; width:100%; height:100%; display:block; background:#04050c}
.sh-is-fallback .sh-canvas{display:none}                 /* hide canvas -> gradient shows */

.sh-scrim{position:absolute; inset:0; z-index:1; pointer-events:none;     /* keep text readable */
  background:linear-gradient(102deg, rgba(4,5,12,.9) 0%, rgba(4,5,12,.55) 32%, transparent 76%);}
.sh-ui{position:absolute; inset:0; z-index:3; display:flex; flex-direction:column; padding:clamp(20px,3.2vw,42px)}

/* decorative animations are frozen for reduced-motion users (JS also draws a single static frame) */
@media (prefers-reduced-motion: reduce){ .sh-h em{animation:none} }
```

## JavaScript

The shaders (verbatim) plus the renderer, the DPR-aware resize, the reduced-motion / WebGL-failure fallbacks, and the loop. (The live HUD text updates and the off-screen IntersectionObserver pause from the full demo are omitted here for brevity.)

```js
var stage  = document.getElementById('shStage');
var canvas = document.getElementById('shCanvas');
var reduce = !!(window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches);

function bail(){ stage.classList.add('sh-is-fallback'); }   // -> CSS gradient shows
if (typeof THREE === 'undefined') { bail(); }               // CDN blocked / SRI mismatch
else {
  // three.js injects the built-in attributes (position, uv) - do not redeclare them.
  var VERT = `varying vec2 vUv;
void main(){
  vUv = uv;
  gl_Position = vec4(position.xy, 0.0, 1.0);   // PlaneGeometry(2,2) already spans clip space
}`;

  // three.js prepends its own precision + built-in uniforms, so precision is omitted here.
  var FRAG = `varying vec2 vUv;
uniform float uTime;
uniform vec2  uResolution;
uniform vec2  uMouse;

const vec3 C_INK     = vec3(0.024, 0.031, 0.063);
const vec3 C_TEAL    = vec3(0.071, 0.561, 0.553);
const vec3 C_VIOLET  = vec3(0.380, 0.255, 0.918);
const vec3 C_MAGENTA = vec3(0.969, 0.231, 0.561);
const vec3 C_GLOW    = vec3(0.62, 0.92, 1.00);

float hash(vec2 p){
  p = fract(p * vec2(123.34, 456.21));
  p += dot(p, p + 45.32);
  return fract(p.x * p.y);
}
float noise(vec2 p){
  vec2 i = floor(p), f = fract(p);
  vec2 u = f * f * (3.0 - 2.0 * f);
  return mix(mix(hash(i + vec2(0.0, 0.0)), hash(i + vec2(1.0, 0.0)), u.x),
             mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y);
}
float fbm(vec2 p){
  float v = 0.0, a = 0.5;
  mat2 m = mat2(1.6, 1.2, -1.2, 1.6);
  for(int i = 0; i < 5; i++){ v += a * noise(p); p = m * p; a *= 0.5; }
  return v;
}
void main(){
  // aspect-correct, screen-centred coordinates
  vec2 p = (gl_FragCoord.xy - 0.5 * uResolution.xy) / uResolution.y;
  p *= 1.55;
  p += (uMouse - 0.5) * 0.55;        // gentle pointer parallax
  float t = uTime * 0.05;

  // two-stage domain warp: noise feeding into noise => flowing plasma
  vec2 q = vec2(fbm(p + vec2(0.0, t)),
                fbm(p + vec2(5.2, 1.3) - t * 0.6));
  vec2 r = vec2(fbm(p + 3.4 * q + vec2(1.7, 9.2) + t * 0.9),
                fbm(p + 3.4 * q + vec2(8.3, 2.8) - t * 0.7));
  float f = fbm(p + 3.6 * r);

  vec3 col = C_INK;
  col = mix(col, C_TEAL,    smoothstep(0.05, 0.62, f));
  col = mix(col, C_VIOLET,  smoothstep(0.30, 0.95, dot(q, q) * 0.85));
  col = mix(col, C_MAGENTA, smoothstep(0.55, 1.05, r.x + 0.45));
  col += C_GLOW * pow(smoothstep(0.55, 0.97, f), 3.0) * 0.55;   // bright filaments

  float vig = smoothstep(1.30, 0.30, length((vUv - 0.5) * vec2(uResolution.x / uResolution.y, 1.0)));
  col *= mix(0.40, 1.0, vig);
  col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.03;              // dither, kills banding
  col = pow(max(col, 0.0), vec3(0.9));                          // mild gamma lift

  gl_FragColor = vec4(col, 1.0);
}`;

  var renderer, scene, camera, material;
  var mouse  = new THREE.Vector2(0.5, 0.5);
  var target = new THREE.Vector2(0.5, 0.5);
  try {
    renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: false, alpha: false, powerPreference: 'high-performance' });
    renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));   // cap DPR at 2
    material = new THREE.ShaderMaterial({
      vertexShader: VERT, fragmentShader: FRAG, depthTest: false, depthWrite: false,
      uniforms: {
        uTime:       { value: 0 },
        uResolution: { value: new THREE.Vector2(1, 1) },
        uMouse:      { value: mouse }
      }
    });
    scene  = new THREE.Scene();
    camera = new THREE.Camera();                                // shader writes clip-space directly
    scene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material));
  } catch (e) { bail(); }                                       // WebGL context / compile failure

  if (renderer) {
    function draw(t){
      material.uniforms.uTime.value = t;
      mouse.x += (target.x - mouse.x) * 0.06;                   // ease toward pointer
      mouse.y += (target.y - mouse.y) * 0.06;
      renderer.render(scene, camera);
    }
    function resize(){
      var w = stage.clientWidth, h = stage.clientHeight;
      renderer.setSize(w, h, false);                            // false: leave CSS sizing to us
      material.uniforms.uResolution.value.copy(renderer.getDrawingBufferSize(new THREE.Vector2()));
      if (reduce) draw(7.5);
    }
    (window.ResizeObserver ? new ResizeObserver(resize).observe(stage)
                           : window.addEventListener('resize', resize));
    resize();

    if (!reduce) {
      stage.addEventListener('pointermove', function(e){
        var b = stage.getBoundingClientRect();
        target.set((e.clientX - b.left) / b.width, 1.0 - (e.clientY - b.top) / b.height);
      });
      stage.addEventListener('pointerleave', function(){ target.set(0.5, 0.5); });

      var elapsed = 0, last = performance.now();
      (function loop(now){
        var dt = (now - last) / 1000; last = now;
        if (dt > 0.1) dt = 0.1;                                 // clamp tab-switch gaps
        elapsed += dt;
        draw(elapsed);
        requestAnimationFrame(loop);
      })(last);
    } else {
      draw(7.5);                                                // reduced motion -> one static frame
    }
  }
}
```

## How it works

- **One full-screen quad.** `PlaneGeometry(2, 2)` spans clip space (-1..1), so the vertex shader can write `gl_Position = vec4(position.xy, 0.0, 1.0)` directly — no camera/projection math, no per-vertex work.
- **The fragment shader does everything**, once per pixel. We feed it three uniforms: `uTime` (animation clock), `uResolution` (to aspect-correct `gl_FragCoord` and centre the field), and `uMouse` (parallax).
- **Domain warping** is the trick that turns flat noise into a living plasma: the output of `fbm` offsets the *input* coordinates of more `fbm` (`q -> r -> f`). Recursively distorting the sampling space produces those organic swirls instead of static TV-snow.
- **Palette by `smoothstep`:** four brand colours are blended by the warped value and the warp magnitude (`dot(q,q)`, `r.x`), then bright filaments are added where the field peaks. A vignette, a touch of dither, and a gamma lift finish it.
- **The loop** advances a delta-based, clamped `elapsed` time and eases `uMouse`; `setSize` + `getDrawingBufferSize` keep `uResolution` in true device pixels so it stays DPR-correct on every resize.

## Customization

- **Palette:** swap `C_TEAL` / `C_VIOLET` / `C_MAGENTA` / `C_INK` (linear-space 0–1 RGB). `C_GLOW` controls the colour of the bright filaments.
- **Flow speed:** `uTime * 0.05` — raise the multiplier for faster motion.
- **Turbulence:** the warp multipliers `3.4` / `3.6` (higher = more chaotic), and the fbm octave count (`i < 5`).
- **Framing:** zoom with `p *= 1.55`; pointer influence with `(uMouse - 0.5) * 0.55`.
- **Mood:** the vignette `smoothstep(1.30, 0.30, …)` and its `0.40` darkness floor; drop the floor for a moodier, more contained glow.

## Accessibility & performance

- **Reduced motion is respected twice:** a CSS `@media (prefers-reduced-motion: reduce)` freezes decorative CSS animations, and the JS checks `matchMedia('(prefers-reduced-motion: reduce)')` — when set it renders **one static frame** and never starts the `requestAnimationFrame` loop.
- **Graceful failure:** renderer creation + shader compile are wrapped in `try/catch`, and `typeof THREE` is checked first. On any failure the stage gets `.sh-is-fallback`, the canvas is hidden, and the CSS gradient shows instead of a broken/blank `<canvas>`.
- **DPR is capped at 2** (`setPixelRatio`) so retina displays don't render 4–9× the pixels of a heavy fragment shader.
- The full demo also uses an **IntersectionObserver to pause the loop** when the stage scrolls off-screen.
- Fragment cost ≈ pixels × (fbm calls × octaves). **Keep the canvas contained** (not `100vw`/full-viewport) and the octave count modest. The background is decorative (`aria-hidden`); real content (headline, CTA) lives in the DOM, kept legible by the scrim.

## Gotchas

- **Don't redeclare three's built-ins.** three.js prepends `precision`, the attributes `position`/`uv`/`normal`, and common uniforms. Re-declaring `attribute vec2 position;` / `precision highp float;` in a `ShaderMaterial` causes redefinition errors.
- **GLSL ES 1.00 syntax.** `ShaderMaterial` emits ES 1.00 by default (`varying`, `gl_FragColor`) — don't add a `#version` directive.
- **`uResolution` must be device pixels** (`getDrawingBufferSize`), matching `gl_FragCoord`, and updated on every resize — otherwise the aspect ratio distorts. The HUD can still show CSS pixels for readability.
- **`renderer.setSize(w, h, false)`** — the `false` stops three from overwriting your canvas's CSS width/height; you size the canvas to its container via CSS and let three set only the backing-store resolution.
- **SRI only on static files.** three's versioned UMD is immutable, so `integrity` is safe. Never put SRI on jsDelivr `+esm`/auto-generated bundles — they can change and the hash will start blocking the script.
- **WebGL loop integer bounds:** GLSL ES 1.00 requires a constant loop bound (`for(int i = 0; i < 5; i++)`), so make the octave count a literal, not a uniform.
- **`gl_FragColor` not `gl_FragColor.rgb` only** — always write `.a` (we output `vec4(col, 1.0)`); a stray `0.0` alpha with `alpha:false` can still composite oddly on some drivers.
