/guide
How this
is built
No framework and no build step. One stylesheet, two scripts and a three.js import map. The whole thing is a folder of static files — it deploys by dragging that folder onto any static host.
01 — The field
The hero is a curl-noise GPU particle field. Curl noise is divergence-free: you take the curl of a potential field instead of sampling it directly, which means the resulting flow has no sources or sinks, so particles never bunch up or tear apart. It's the standard trick for fluid-looking motion, and the technique here is adapted from Edan Kwan's Hyper Mix, modernised onto three.js r171 and GPUComputationRenderer.
Position and velocity live in floating-point textures on the GPU. Each frame, a fragment shader advects every particle along the curl field and writes the result back to a texture that becomes next frame's input. Nothing round-trips to the CPU. Post-processing runs through an EffectComposer chain: motion blur accumulated from the previous-position buffer, UnrealBloomPass, SMAA, then film grain.
02 — Two independent presets
Desktop and mobile do not share a defaults object. They're separate complete presets, on purpose: retuning the desktop look must never drag the mobile baseline along with it. Desktop runs slow and structural — low speed, tight radius, fine particles, zero fade so the field holds its shape instead of spraying. Mobile keeps the original faster simulation values with every post-processing pass switched off, which is what actually runs smoothly on a 2020 Adreno.
| Parameter | Desktop | Mobile |
|---|---|---|
| speed | 0.11 | 0.45 |
| fade speed | 0 | 0.004 |
| radius | 0.16 | 0.65 |
| curl size | 0.0005 | 0.0012 |
| particle size | 17 | 40 |
| antialias / blur / bloom / grain | on / on / on / 0 | all off |
Detection is (pointer: coarse) OR a short-edge under 820px, so tablets running a desktop-mode browser still get the mobile preset. Reset restores whichever preset applies to the current device.
03 — The eye
Two controls sit at the top right of the hero: ∞ opens the surface controls, and the eye directly beneath it switches the field off entirely. Off means off — the render loop stops and the canvas fades, so it costs zero GPU rather than quietly rendering behind an opacity of nothing. The slash on the icon draws itself in over 260ms via stroke-dashoffset, and the button carries aria-pressed so it reads correctly to a screen reader.
Because the field can be switched off, the hero has a static ground underneath it — two radial gradients in plum and ember. Hiding the particles reveals a composed background, not a black rectangle.
03b — The glass mark
The infinity over the hero is a raymarched glass object, not an image or an SVG. Its shape is a lemniscate of Bernoulli — (x²+y²)² = 2a²(x²−y²) — swept into a tube. That matters: an earlier version built the figure from two circles union'd together, which leaves a visible crease exactly where the lobes cross, and the crossing is the first place the eye looks. One continuous curve has no seam to hide.
It sits on its own canvas above the particle field and genuinely refracts it — the field is uploaded as a texture each frame, and the ray that exits the back of the glass samples it. Light enters, bends, crosses the body, bends again on the way out, and loses colour to Beer–Lambert absorption on the way. Three materials: iridescent, clear (most dispersion, least bend) and diffused (seven-tap frosted sampling that widens with thickness).
Every hue in it comes from the two particle colours in the panel. Change them and the glass changes — the iridescence is that palette refracted, not a stock rainbow laid on top.
04 — Iridescence, rationed
The holographic gradient appears in exactly two places: as a 1px hairline on work cards, and as a single fast sweep across a card on hover. Nowhere else, and never filling a surface. Foil that covers everything stops reading as foil — the effect depends entirely on being rare and on catching an edge.
The hairline is a conic gradient masked to a 1px border via mask-composite: exclude, with its angle in a registered @property so it can be animated. That angle is written from pointer and scroll position, rAF-throttled, with no permanent loop.
Hard-won rule: a CSS filter on any ancestor of a backdrop-filter element creates a new backdrop root and silently kills the blur. Filters stay on leaf layers.
05 — Accents match the shader
The CSS accents aren't approximations of the particle colours, they're the same hex values the shader renders: --ember: #ff5a1f and --volt: #d8f52e. Change a colour in the surface controls and the type is momentarily out of step with the field, which is a useful reminder that they're genuinely one palette rather than two that happen to look similar.
06 — Work cards
Spec cards, not screenshot tiles: index number, name, one honest sentence, then a data footer. Each card carries a --h hue on its root, which offsets a soft two-point gradient so no two cards read alike. A real screenshot dropped into job__shot sits behind the type at low opacity as texture — so the card never depends on having one, and adding one later is an upgrade rather than a requirement.
To add work: duplicate a .job block, bump the index, set --h, write the sentence. That's the whole CMS.
07 — Performance
Everything animated is transform and opacity. The particle loop pauses via IntersectionObserver once the hero scrolls away and stops on tab-hide, resetting its clock on resume so particles don't lurch forward after a pause. Imagery is WebP, lazy-loaded and async-decoded, and any image that fails to load removes its own frame rather than leaving a broken glyph in the layout.
08 — Deploying
Any static host will serve it: no build command, no framework preset, output directory /. Because there is nothing to compile, a deploy is just a file copy, and every push swaps the whole site atomically.