/2 min read
A particle field that works on white
Notes on the hero animation of this site, built with three.js and React Three Fiber. Most of the work is in what it does not do.
The animation behind the headline on this site is a few thousand points in a tilted lens, drifting on simplex noise, pushed away from the cursor. It took an afternoon to make it move and longer to make it well-behaved. The decisions below are the ones that mattered.
Normal blending, not additive
Almost every particle demo uses additive blending, which is why they all live on black. Additive light on a near-white page is invisible. The points here use normal blending with a soft alpha disc in the fragment shader, so the same material reads as dark dust on #fafafa and pale dust on #0a0a0a.
Colors come from CSS
The site's theme is a set of CSS custom properties. Rather than duplicating the palette in JavaScript, the canvas resolves --color-fg and --color-accent through a probe element and writes them into shader uniforms. A MutationObserver on the theme attribute and a prefers-color-scheme listener trigger the same function, so a theme toggle recolors the field on the next frame without remounting anything.
The renderer is set to linear output with tone mapping off, and the uniforms are plain vectors instead of THREE.Color, so the value that lands on screen is the exact token from the stylesheet.
The pointer lives in world space
The hero section, not the canvas, tracks the pointer; the canvas stays pointer-events: none so the headline and buttons underneath behave normally. Pointer position is converted to normalized device coordinates, then to world units on the z = 0 plane using the viewport size that React Three Fiber already computes. Inside the frame loop it is smoothed with an exponential lerp, which is what makes the repulsion feel like it has mass. None of this touches React state.
When it does not run
- It mounts only after
requestIdleCallback, so the three.js chunk never competes with the headline and the fonts for first paint. frameloopis derived from three booleans: intersecting the viewport, the tab being visible, andprefers-reduced-motion. Off-screen or hidden means no frames at all; reduced motion means one static frame, rendered on demand.- A CSS dot pattern sits underneath the canvas as a poster, so no-JS visitors and the second before WebGL is ready see the same composition.
- Point count and pixel ratio are decided once from viewport width, pointer type and a rough device-memory check. Coarse pointers get no repulsion at all, because a finger dragging through a hero is a scroll, not a hover.
What I would do differently
Nothing structural. If I add anything it will be a click: a single pulse through the field, then nothing. The rule for a hero animation is the same as for the rest of the page. It should be possible to ignore.
- three.js
- webgl
- design