Skip to content

Creative Coding

Shader Backgrounds That Behave Themselves.

Ambient, accessible, and allowed to fail quietly.

Aidxn
Written by Aiden Wood Founder and Lead Design Engineer
Visual layer, readable content A shader can add ambient motion behind a stable text and action layer. The message stays available when WebGL or motion is unavailable. NOISEDECISION

Visual layer, readable content

A shader can add ambient motion behind a stable text and action layer. The message stays available when WebGL or motion is unavailable.

Preview Shader Backgrounds

A shader is not a conversion rate. It is a visual layer. Good ambient WebGL gives a page a pulse while copy, contrast, and action remain in charge.

The production standard is not an impressive demo on a developer laptop. It is a readable section on a small phone, a static fallback when WebGL fails, no essential information inside a canvas, and zero movement when a visitor asks for reduced motion.

Start with a content first wrapper

Velocity Components ship the shader behind the content, not through it. The wrapper owns three layers: the canvas, a contrast scrim, then ordinary semantic HTML. The canvas is decorative, so assistive technology does not need to navigate it.

---
import ShaderBackground from '../components/brand/ShaderBackground.astro';
---

<ShaderBackground
  shader='blob'
  type='brand'
  preset='hero'
  opacity={0.72}
  scrim='bottom'
>
  <section class='hero-content'>
    <p>Velocity Components</p>
    <h1>Motion with a job.</h1>
    <a href='/components/'>Browse components</a>
  </section>
</ShaderBackground>

The component treats its canvas as aria-hidden. That is correct only because the canvas is decoration. If a graphic explains product data, provide an equivalent table, text summary, or labelled SVG instead.

A complete fragment shader needs predictable uniforms

Raw GLSL gets brittle when every file invents its own contract. The Velocity runner supplies u_time, u_resolution, u_mouse, and u_dark. A fragment can then stay small and testable.

// The runner injects precision plus these uniforms:
// u_time, u_resolution, u_mouse, u_dark

void main() {
  vec2 uv = gl_FragCoord.xy / u_resolution.xy;
  vec2 p = uv - 0.5;
  p.x *= u_resolution.x / u_resolution.y;

  float radius = length(p);
  float ring = smoothstep(0.28, 0.275, abs(radius - 0.22));
  float drift = 0.5 + 0.5 * sin(u_time * 0.25 + radius * 18.0);

  vec3 light = vec3(0.96, 0.94, 1.0);
  vec3 dark = vec3(0.05, 0.04, 0.08);
  vec3 base = mix(light, dark, u_dark);
  vec3 accent = vec3(0.62, 0.38, 0.96) * (0.45 + drift * 0.25);

  gl_FragColor = vec4(base + accent * ring, 1.0);
}

This is intentionally quiet. Make the ambient image lower contrast than the copy. A shader competing with a heading is decoration that has forgotten its job.

Cap pixels before chasing frames

Canvas cost grows with drawing buffer pixels. A fullscreen canvas on a high density phone can become needlessly expensive before any creative code runs. Cap device pixel ratio, resize from the element box, and do not leave far offscreen canvases running.

const maxDpr = 1.5;
const dpr = Math.min(window.devicePixelRatio || 1, maxDpr);

function resizeCanvas(canvas: HTMLCanvasElement, host: HTMLElement) {
  const { width, height } = host.getBoundingClientRect();
  canvas.width = Math.max(1, Math.floor(width * dpr));
  canvas.height = Math.max(1, Math.floor(height * dpr));
  canvas.style.width = width + 'px';
  canvas.style.height = height + 'px';
}

The shipped Shader Backgrounds component goes further: it caps the drawing buffer, creates a WebGL context near the viewport, releases it offscreen, respects reduced motion, and falls back to a brand gradient if setup fails. Those guards are why it belongs in a reusable library rather than copied from a CodePen.

Do not bind every scroll event to a redraw

Keep scroll input in a mutable value and read it inside the existing animation frame. That avoids starting a second rendering loop. If the effect does not need continuous motion, render a static frame or stop entirely.

let scrollTarget = 0;

window.addEventListener('scroll', () => {
  const max = document.documentElement.scrollHeight - window.innerHeight;
  scrollTarget = max > 0 ? window.scrollY / max : 0;
}, { passive: true });

function frame(time: number) {
  const motionAllowed = !matchMedia('(prefers-reduced-motion: reduce)').matches;
  setUniform('u_scroll', scrollTarget);
  draw(motionAllowed ? time : 0);
  if (motionAllowed) requestAnimationFrame(frame);
}

Accessibility and fallback checklist

  • Keep copy, controls, and error states outside the canvas.
  • Use a scrim and test contrast with the shader at its brightest frame.
  • Make decorative canvases aria-hidden.
  • Render a static frame or gradient for reduced motion and WebGL failure.
  • Test a real low power phone before declaring the effect ready.

Sources and further reading

Let us make some quick suggestions?

Please provide your full name.
Please provide your phone number.
Please provide a valid phone number.
Please provide your email address.
Please provide a valid email address.
Please provide your brand name or website.
Please provide your brand name or website.