IInbox
Composed

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

Usage

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

shaderReads as
meshthree colour wells drifting through each other
grainthe same field under moving film grain
ditherquantised on the Bayer 8×8
halftonea 45° print screen over the gradient
metaballsfive fields summed, thresholded, rim-lit
voronoianimated cells, darkened toward their edges
neurodomain-warped fbm — noise sampled at noise
causticsiterated distortion with a focused highlight
iridescentthin-film interference — spectral
chromeliquid metal, banded reflection
aurorathree curtains wandering on noise
silkfine striations through a slow warp
glassa displaced lookup — refraction, not blur
plasmafour wave systems on non-multiple frequencies
grida perspective floor derived without matrices
rippledamped waves centred on the cursor
fluidcurl noise — circulating, not smeared
kaleidopolar space folded into six segments
starbursta four-point emitter with a blown-out core
godraysshafts splayed by angle from a bright floor
flareone point of light, smeared the way a lens does
orba lit sphere — real normal, curved terminator
ringsconcentric waves resolved through the print screen
ribbonfour spectral bands summing where they cross
duska sun below the edge — the gradient is its glow
beamsdiagonal shafts on uneven widths
columna tight core inside a wide haze, with a pool
spectralfive wells, five hues off the cosine palette
haloan eclipse: the disc is occluded, the ring is not
satina few broad folds with the sheen riding them
prismdispersion as an angle, not a colour ramp
embertwo 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 shader relinks 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.
  • ShaderThumb shares 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-motion renders 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 getShaderInfoLog is 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.