How it was built
Guide
Heliotrope is one full-screen WebGL quad and a hand-written fragment shader. No framework, no CDN, no three.js. Here is exactly how the field works and how to rebuild it.
The field is a single fragment shader
The living colour is one fullscreen triangle rendered by a GLSL fragment shader. Every pixel computes its own colour each frame from a noise field. There is no geometry and no texture. The whole visual is math evaluated on the GPU, which is why it stays smooth while filling the screen.
Domain-warped FBM
The structure comes from fractal brownian motion (FBM): value noise summed over five octaves at halving amplitude. FBM alone looks like clouds. To get aurora curtains we warp the domain: we sample FBM, feed that back into the coordinates, and sample again. This is Inigo Quilez's classic warp, two levels deep.
vec2 q = vec2(fbm(p + t), fbm(p + 5.2 - 0.8*t));
vec2 r = vec2(fbm(p + 3.0*q + 1.7),
fbm(p + 3.0*q + 8.3));
float f = fbm(p + 2.6*r); // the curtain fieldA slow time offset moves the sampling point, so the curtains drift. The pointer nudges the same coordinate by a tiny amount, which is why the field leans toward the cursor without ever chasing it.
Colour is a wide spectral ramp, kept painterly
Six anchors, violet to rose, are interpolated with smoothstep across the field. The trap here is neon. Two rules keep it painterly instead: the colour only shows inside bright curtain zones (dark void fills the gaps, the way real aurora has black sky between bands), and the whole frame is desaturated toward its own luminance by about a third. Colour reads as weather, not as a light show.
The dominant band is a uniform. Each reading section carries a data-band value; the render loop finds whichever section is centred in the viewport and eases the uniform toward its band. Scrolling the page literally shifts the colour weather.
The impossible band
Reading III raises a second uniform that blends two ramp samples which should not co-exist, chartreuse and rose, and re-saturates the field. It is the one moment the restraint lifts.
WebGL is an enhancement, never a dependency
The shader is a lazy island. A designed multi-stop CSS gradient paints first, as the poster and as the permanent fallback. After the page loads, an idle callback initialises WebGL and fades the canvas in over that gradient. If WebGL is missing, the compile fails, or JavaScript never runs, the gradient stays and all content is present. Nothing about the page depends on the shader existing.
- Device pixel ratio is capped near 1.5 on desktop and lower on phones, so the fill cost stays flat.
- The loop pauses when the tab is hidden.
- Under
prefers-reduced-motionthe shader renders exactly one static frame and never starts the animation loop. It is still the real field, just held still.
CSP-clean motion
The site ships a strict hash-based Content Security Policy: no unsafe-inline, no inline style attributes, no inline event handlers, no external scripts. That rules out most animation libraries. It does not rule out WebGL, because the canvas drawing API never touchesstyle-src. All state is carried on data-attributes and toggled classes; the shader logic lives in an external module that Astro bundles and hashes. Reveal animations are CSS driven by a small IntersectionObserver that only adds a class.
Type and tools
- Fraunces variable serif (optical-size axis) for the colossal display readings.
- Hanken Grotesk for instrument text.
- JetBrains Mono for coordinates, spectral data and the archive, with tabular figures.
- All three are self-hosted through Fontsource, so no font request leaves the origin.
- Astro plus Tailwind, deployed to Cloudflare Pages.
- Zero AI-generated raster shipped. The colour is code. See Prompts.