The background on this site is a WebGL shader running in Three.js. No textures, no geometry beyond a single quad. Everything comes from noise in a fragment shader.
This is how it works.
The setup
The renderer attaches to a <canvas> positioned behind the page content. An orthographic camera looks at a single plane that fills the clip space exactly - vertices at (-1,-1), (1,-1), (1,1), (-1,1). The fragment shader runs once per pixel. There’s no scene, no lighting, no 3D geometry - just the shader deciding what colour each pixel should be.
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const geometry = new THREE.PlaneGeometry(2, 2);
const material = new THREE.ShaderMaterial({ vertexShader, fragmentShader, uniforms });
The vertex shader is a single line:
void main() {
gl_Position = vec4(position, 1.0);
}
All the work happens in the fragment shader.
Simplex noise
The shader uses 3D simplex noise - a GLSL implementation of the classic Perlin simplex algorithm. It takes a vec3 and returns a float in roughly [-1, 1].
The third component is time. Passing vec3(x, y, t) gives a noise value at position (x, y) that smoothly evolves as t increases. That’s how the animation works - time ticks forward each frame and the noise field evolves.
Fractional Brownian motion
A single noise sample looks smooth but bland. FBM layers multiple octaves of noise at increasing frequencies and decreasing amplitudes:
float fbm(vec3 p) {
float value = 0.0;
float amplitude = 0.5;
float frequency = 1.0;
for (int i = 0; i < 5; i++) {
value += amplitude * snoise(p * frequency);
frequency *= 2.0;
amplitude *= 0.5;
}
return value;
}
Five octaves. Each doubles the frequency and halves the contribution. The result has the large-scale structure of low-frequency noise with fine detail layered on top - the characteristic look of clouds or fluid.
Domain warping
The most important detail is the warp pass before the FBM calls. Rather than sampling noise at the raw screen coordinates, two noise values are used to offset the coordinates first:
float warp1 = snoise(vec3(p * 0.4 + vec2(3.7, 2.3), t * 0.3));
float warp2 = snoise(vec3(p * 0.5 + vec2(-1.5, 4.1), t * 0.35 + 5.0));
vec2 warped = p + vec2(warp1, warp2) * 0.08;
Then the FBM samples use warped instead of p. Warping the input domain like this breaks the grid-like regularity of straight noise and produces the swirling, organic shapes that make it look fluid rather than mathematical.
Three noise layers to colour
Three separate noise values drive three colour blends:
float n1 = fbm(vec3(warped * 0.65, t));
float n2 = fbm(vec3(warped * 0.45 - 0.5, t * 0.6 + 10.0));
float n3 = snoise(vec3(warped * 0.35 + vec2(n1, n2) * 0.1, t * 0.4));
n1 and n2 are both FBM at different scales and offsets. n3 is a single simplex sample that uses n1 and n2 to further warp its own input - layered domain warping. Each noise value gets smoothstep-ed to a blend factor, then used to mix the base background colour toward one of three palette colours.
Per-page palettes
Each section of the site has its own palette - three colours that define the tone of that area:
const palettes = {
home: { col1: [0.20, 0.24, 0.50], col2: [0.30, 0.18, 0.44], col3: [0.16, 0.28, 0.40] },
writing: { col1: [0.08, 0.24, 0.28], col2: [0.08, 0.20, 0.18], col3: [0.10, 0.16, 0.36], strength: 0.7 },
projects: { col1: [0.18, 0.10, 0.44], col2: [0.10, 0.18, 0.40], col3: [0.08, 0.28, 0.38] },
// ...
};
The strength parameter controls how much the colours bleed through. The writing section uses 0.7 to keep the background subtle behind long-form text.
Values are normalised linear RGB, which maps directly to the vec3 uniforms the shader expects. No conversion required.
Light mode
The shader has a uLight uniform - a float ranging from 0 (dark) to 1 (light). Both dark and light palette colours are passed as uniforms, and the shader mixes between them based on uLight.
Light mode colours are derived programmatically - a pastel tint of the dark palette:
const lightCol = (c: [number, number, number]) =>
new THREE.Vector3(0.78 + c[0] * 0.18, 0.78 + c[1] * 0.18, 0.82 + c[2] * 0.14);
A MutationObserver watches for changes to the data-theme attribute on <html> and updates the uniform immediately. No page reload, no flash.
Mouse influence
Mouse position is passed as a uMouse uniform and nudges the warp offsets slightly. The effect is subtle - moving the mouse shifts the fluid rather than dramatically redirecting it.
Lerping the mouse position smooths the movement:
mouseCurrent.lerp(mouseTarget, 0.04);
uniforms.uMouse.value.copy(mouseCurrent);
A lerp factor of 0.04 means the shader position catches up to the real cursor position over about 25 frames - slow enough to feel fluid, fast enough to feel responsive.
move your mouse over the canvas
Performance
Pixel ratio is capped at 2x:
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
At 3x or higher the shader runs on significantly more pixels with no visible improvement. Capping saves a third of the work on high-DPI displays.
The canvas covers the full document height, not just the viewport. A ResizeObserver on document.body re-measures when content height changes - images loading, accordions opening - and updates the renderer size and uniforms accordingly.
One pixel of dither is added before output to prevent colour banding on gradients:
vec3 dither = vec3(fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453));
color += (dither - 0.5) / 255.0;
Astro view transitions
The site uses Astro’s View Transitions for navigation. Without cleanup, navigating to another page would leave the previous animation frame loop running, stacking more and more requestAnimationFrame callbacks with each navigation.
The fix is cleaning up on astro:before-swap and reinitialising on astro:after-swap:
document.addEventListener('astro:before-swap', () => {
cancelAnimationFrame(animationId);
resizeObserver.disconnect();
themeObserver.disconnect();
renderer.dispose();
material.dispose();
geometry.dispose();
});
document.addEventListener('astro:after-swap', initFluid);
The new page gets a fresh palette, a fresh random time offset so it doesn’t start from the same noise state every time, and a clean animation loop.