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








