The homepage sits on a field of a few dozen to a hundred-odd points, spaced in a loose grid with a little jitter so it doesn’t read as a pattern. Left alone, they sit perfectly still. Bring a cursor within about 170 pixels and the nearby points get pulled toward it, then eased back home the moment the cursor moves on. That’s the whole interaction — no scenes, no timelines, no config panel. This post is the engineering underneath it, and the decisions that made it tasteful instead of expensive.
The one constraint that shaped everything: zero cost at rest
A field that idles at 60fps because “it’s just a canvas” is a field that drains a laptop battery to render a decorative background nobody’s touching. That’s the thing I refused to ship, and it drove every other choice.
So the render loop only requests another frame when something is actually moving — a particle still easing home, or the pointer still active. The instant every point settles within its threshold, the loop stops itself. No requestAnimationFrame firing, no work, nothing on a profiler. The default state of the animation is genuinely no animation at all. (This is the literal, in-code version of a principle I wrote about separately in Motion Born From Stillness.)
The decisions that mattered more than expected
The interaction is simple; making it feel right and cost nothing came down to a handful of unglamorous calls:
| Decision | The naive approach | What it actually took | Why |
|---|---|---|---|
| Pull strength | Linear falloff by distance | Squared falloff | Linear felt uniformly magnetic; squared feels like a real field with a soft edge |
| When to stop reacting | Stop on the last move event | Stay “active” for 900ms after | So the field doesn’t snap to rest the instant a hand pauses mid-gesture |
| Weaker devices | Ship one version to everyone | Coarser grid + capped pixel ratio on low-core devices; touch skips it entirely | The same file shouldn’t tax a mid-range phone to look crisp on a desktop |
| Reduced motion | A toggle bolted on later | Field renders once, statically; loop never starts | It’s a promise not to move, so nothing moves — not a dimmer switch |
Reduced motion is load-bearing, not a courtesy
That last row deserves its own paragraph, because it’s the one most often treated as an afterthought. When a visitor has asked their system not to animate things, the field renders a single static frame and the render loop never starts at all. Same visual object, no motion promised, none delivered. Building that in from the start — rather than as a late accessibility patch — is what lets the rest of the code stay simple, because “not moving” is already a first-class state the whole system understands.
If you’re building ambient motion
- Decide your rest-state cost first. If the answer is "60fps forever," redesign until idle means zero frames. Battery is a feature.
- Make the loop self-stopping: only request the next frame while something is genuinely in motion, and let it halt on its own.
- Profile on a mid-range phone, not your dev machine. Add device tiering — coarser detail, capped pixel ratio — before it's a complaint.
- Treat reduced-motion as a real state from the start: a static render and a loop that never begins, not a feature you gray out later.
- Tune the feel, not just the function. Squared falloff, a short idle grace period — the small curves are what make it read as alive rather than mechanical.