Painting with noise
The aurora behind this site — fractal noise, curl, and three colors, in one WebGL fragment shader.
The background of this site's front page is not a video, not a canvas gradient, and not 4 MB of Lottie. It is one fragment shader, about a hundred lines of GLSL, repainting the sky sixty times a second for roughly the energy cost of scrolling a webpage. This post is the whole recipe.
Noise is the pigment
Everything organic-looking in shader land starts with value noise or simplex noise — a smooth random field. Stack a few octaves at doubling frequencies and you get fractal Brownian motion (fBm), the texture of clouds, marble, and — with the right palette — polar skies.
float fbm(vec2 p) {
float v = 0.0, a = 0.5;
for (int i = 0; i < 5; i++) {
v += a * snoise(p);
p = p * 2.03 + 17.0; // decorrelate octaves
a *= 0.55;
}
return v;
}Raw fBm is static texture. The aurora trick is domain warping — feed noise its own output as coordinates, then drag the domain with time:
vec2 q = vec2(fbm(p + uTime * 0.06), fbm(p + vec2(5.2, 1.3)));
vec2 r = vec2(fbm(p + 1.8 * q + uTime * 0.09), fbm(p + 1.9 * q));
float field = fbm(p + 1.7 * r);Warped noise stops looking like static and starts looking like weather. Iñigo Quílez has the canonical article; I have never once shipped a shader without his site open in another tab.
Three colors, borrowed from the UI
The palette isn't hardcoded. The page hands the shader the same OKLCH-derived roles the buttons use — primary, tertiary, secondary — as linear-RGB uniforms, and the field value picks along a two-stop gradient with a highlight kiss on top:
vec3 col = mix(uColorA, uColorB, smoothstep(0.15, 0.85, field));
col = mix(col, uColorC, pow(max(r.y, 0.0), 2.5) * 0.6);
col *= vertical; // fade toward horizonBecause the uniforms come from the design system, re-theming the site re-lights the sky. No shader edits, no second palette to maintain.
The parts that keep it honest
A pretty shader that eats batteries is a bug with good lighting. Prism's scene guards itself:
- Resolution cap. The canvas renders at
min(devicePixelRatio, 1.75), and drops to 1.0 when the tab reports low FPS. Auroras are low-frequency; nobody can tell. - Visibility.
IntersectionObserver+visibilitychangepause the loop the moment the canvas scrolls away or the tab hides. Scroll down and the GPU goes quiet. - Reduced motion.
prefers-reduced-motionfreezes time at a nice frame — you still get the painting, just not the weather. - No WebGL? The
<canvas>sits on top of a CSS gradient built from the same tokens. Failure looks like a design choice.
Debugging noise
You cannot console.log a fragment. You can output your intermediates as color, which is better:
// gl_FragColor = vec4(vec3(field), 1.0); // inspect the raw field
// gl_FragColor = vec4(q * 0.5 + 0.5, 0.0, 1.0); // inspect the warpHalf of shader authorship is commenting one of these back in, squinting, and adjusting a single magic number by 0.1. It is the most meditative programming I know. The sky above this text took about forty evenings of it, and I'd call every one of them well spent.