Shader canvas
Thirty-two multi-pass GPU effects on raw WebGL2, each with its own art direction.
A WebGL2 surface that runs a fragment shader from a library of thirty-two through a multi-pass pipeline.
Each shader has its own palette, and it is not the design system's
This is deliberate, and it was a reversal. The first version drove every shader from the brand ramp — one palette, everything coherent — and it produced effects that were all the same blue. The palette was flattening them harder than any shared maths could. Two effects with genuinely different behaviour are still two tiles nobody can tell apart if they are the same colour.
So each entry in the library carries its own three colours and its own ground,
as hex, in SHADERS[].palette. A shader here is art direction; the components
around it are what carry the design system. tests/dom/shaders.test.tsx asserts
that no two shaders share a ramp, because a duplicate is the failure this was
meant to fix.
They all emit against ink
Every shader composites over a near-black ground, and that single decision does more for how these look than any other.
Light only reads as light against something dark. Run the identical shader over a white page and it becomes a wash with a tint — the failure mode that makes most WebGL backgrounds look amateur. Relatedly, the effects build colour additively out of the ground rather than mixing toward it: a mix can never be brighter than its brightest input, so overlapping wells stay flat, while summed light gets hotter where they cross.
The ink is never pure black, and it is never neutral: it is a very dark version of that effect's own hue. A magenta effect over a cold black looks pasted on rather than lit.
None of this follows the theme. A light page does not make emitted light brighter, it makes it invisible.
The bench, with every parameter live, is at /lab/shaders.
Installation
npx shadcn@latest add https://ui.whoisroktim.lol/r/shader-canvas.jsonUsage
Give it a size; it fills its element.
import { ShaderCanvas } from "@/components/app/shader-canvas";
<div className="relative h-[420px] overflow-hidden rounded-xl">
<ShaderCanvas shader="mesh" className="absolute inset-0 h-full w-full" />
<div className="relative z-10 p-8">…content…</div>
</div>;Cursor distortion is opt-out
The pointer warps the image through the displacement pass, and it is on by
default. Turn it off with interactive={false}:
<ShaderCanvas shader="dusk" interactive={false} />Worth turning off for anything sitting behind content. A background that reacts to the cursor competes with whatever the cursor is actually there to do, and on a long page it means the hero is never still while someone is reading.
It is a prop rather than just params.mouse = 0 for two reasons. The listeners
stop firing entirely, so an inert canvas costs nothing per pointer move. And the
pointer uniform is held at the centre rather than frozen wherever the cursor
happened to be — otherwise ripple, which reads the pointer as its origin,
would keep its rings stuck off in a corner. Toggling it back on re-arms
immediately; it does not latch.
params.mouse still scales how strongly the cursor pulls, for when you want the
effect present but subtler.
Shaders
shader | Reads as |
|---|---|
mesh | three colour wells drifting through each other |
grain | the same field under moving film grain |
dither | quantised on the Bayer 8×8 |
halftone | a 45° print screen over the gradient |
metaballs | five fields summed, thresholded, rim-lit |
voronoi | animated cells, darkened toward their edges |
neuro | domain-warped fbm — noise sampled at noise |
caustics | iterated distortion with a focused highlight |
iridescent | thin-film interference — spectral |
chrome | liquid metal, banded reflection |
aurora | three curtains wandering on noise |
silk | fine striations through a slow warp |
glass | a displaced lookup — refraction, not blur |
plasma | four wave systems on non-multiple frequencies |
grid | a perspective floor derived without matrices |
ripple | damped waves centred on the cursor |
fluid | curl noise — circulating, not smeared |
kaleido | polar space folded into six segments |
starburst | a four-point emitter with a blown-out core |
godrays | shafts splayed by angle from a bright floor |
flare | one point of light, smeared the way a lens does |
orb | a lit sphere — real normal, curved terminator |
rings | concentric waves resolved through the print screen |
ribbon | four spectral bands summing where they cross |
dusk | a sun below the edge — the gradient is its glow |
beams | diagonal shafts on uneven widths |
column | a tight core inside a wide haze, with a pool |
spectral | five wells, five hues off the cosine palette |
halo | an eclipse: the disc is occluded, the ring is not |
satin | a few broad folds with the sheen riding them |
prism | dispersion as an angle, not a colour ramp |
ember | two noise fields convecting upward |
Gradients that are lit
A gradient made by interpolating two colours across a rectangle always looks
like exactly that. The last eight build the ramp out of a light source — a sun
under the horizon, a shaft, a refracting fan — so the falloff has a cause. It is
the same reason godrays works: you are not looking at a ramp, you are looking
at distance from something.
Two of them are one parameter away from being something else, which is worth
knowing before you tune them. Raise the fold frequency in satin and it becomes
silk. Scroll ember's noise sideways instead of upward and it becomes marble —
the convection is the whole read.
Parameters
{
speed: 1, scale: 1, grain: 0.035, pixel: 1, levels: 4,
soft: 0.55, bloom: 0.35, aberration: 0.35, vignette: 0.25,
displace: 0.35, mouse: 0.6,
}soft, bloom, aberration and vignette belong to the composite pass;
displace and mouse to the displacement pass. mouse scales a cursor lens
that warps the image toward the pointer, and it is gated on the pointer actually
being over the canvas — otherwise every surface on a page leans toward a cursor
nowhere near it.
pixel snaps the sample point to a grid, which is what makes a coarse
quantisation look deliberate rather than merely low-resolution. levels only
affects dither. Each shader carries its own sensible starting point in
SHADERS[].defaults — the usable range for a halftone screen and a metaball
field have almost nothing in common.
Two notes on the effects
Metal is banded. chrome reads as metal because its reflection steps rather
than falls off smoothly; a smooth falloff looks like plastic no matter how shiny
it is. Silk is frequency. Drop the striation frequency in silk and it
collapses into another gradient — the fine lines are the whole effect.
Both iridescent and chrome estimate a surface normal from the gradient of
the noise field, which costs two extra fbm samples per pixel. That is the
price of anything that needs to look lit rather than merely coloured.
Why there is no three.js
The whole runtime is a vertex shader with no attributes, one program per effect,
and a drawArrays of three vertices. The triangle is derived from
gl_VertexID, so there is no buffer and no VAO. Adding a scene graph to animate
a rectangle would cost more than the feature.
What it handles that a naive shader canvas does not
- Pauses when off-screen, via
IntersectionObserver. A gallery of live shaders otherwise runs a GPU loop for every one you cannot see. - Survives being switched. Changing
shaderrelinks one program; it does not rebuild the context or the render targets. An earlier version tore everything down per switch and the canvas died partway through the gallery. ShaderThumbshares a single context across a whole grid. One context per tile exhausts GPU memory, and the driver reclaims the oldest — so the first tiles go dead while the later ones look fine, which is invisible until you screenshot the page.prefers-reduced-motionrenders a single held frame rather than hiding the effect. It still redraws on resize and on a theme change.- Context loss. The GPU can reclaim the context at any time; without a handler the canvas goes black permanently with nothing in the console.
- Compile errors are shown on the surface. A silent black rectangle tells
you nothing about which line is wrong, so
getShaderInfoLogis rendered in place.
Device pixel ratio is capped at 2 — a fullscreen fragment shader at 3× on a phone is three times the fragments for no visible gain.
Sharing a pattern with the transition
dither quantises on the same canonical Bayer 8×8 as the
Transitions component. That is asserted in
tests/dom/shaders.test.tsx rather than assumed: the test parses the table out
of the generated GLSL and compares it cell for cell against the TypeScript
matrix, so a page can cross-fade between the two without the texture shifting.