Skip to content

Demo 05 · Lab

The constellation field

Canvas 2D, no library. Thousands of specks on a flow field, a hundred brighter nodes linking to their neighbours, and the whole thing leaning toward your cursor.

Use this when

The effect is made of countable, separate things, and you want code you can open and change without learning a shader language.

Skip it if: The effect is a continuous field of colour. A shader draws that for less, and canvas costs you by the object.

The prompt

The constellation field prompt
Build a dense canvas 2D particle background as a self-contained React client component. No libraries: plain canvas, no particle library, no three.js.

TWO LAYERS. This is the thing that makes density affordable.

- DUST: around 4,800 specks at 1440x810, scaled by area with a floor of 900 so a phone draws far fewer. 1–2px each, drawn with fillRect (a rect that size is materially cheaper than arc + fill, and nobody can tell). No links. This layer carries the density and most of the motion.
- NODES: around 130 larger points that DO link to close neighbours. Linking is O(n²), so only this small set pays for it, the dust behind supplies the "everywhere" without the cost.

MOTION, which has to be visible

- Dust drifts at roughly ±0.9px per frame, and is pushed around by a cheap standing-wave flow field: add sin(y * 0.0045 + t * 0.34) * 0.02 to vx and cos(x * 0.0052 - t * 0.29) * 0.02 to vy each frame, then damp both by 0.985 so the field does not accelerate without bound. Two sines are not curl noise, but they bend the paths enough that the field swirls instead of travelling in straight lines, for two trig calls per particle.
- Each speck twinkles on its own phase and rate, alpha = base * (0.62 + 0.38 * sin(t * rate + phase)). Without this the field reads as a fixed texture that happens to slide.
- Nodes drift slower, around ±0.5px per frame.
- Everything wraps with a 24px margin so nothing pops at the edges.
- The cursor does NOT attract or repel. The whole field parallaxes up to 18px away from the pointer, eased at 0.045 per frame, and the dust parallaxes about 1.9x further than the nodes for cheap depth.

PERFORMANCE, because canvas costs per draw call

A state change (fillStyle or globalAlpha) costs about as much as the draw itself, so setting alpha per speck doubles the cost of the entire pass. Quantise alpha into 8 buckets, group specks by (colour, bucket) into reused arrays, and pay ONE fillStyle per group for hundreds of rects. Use two colour groups only, violet for about 82% and warm orange for the rest, so the whole dust layer costs roughly 16 state changes per frame instead of thousands. Same bucketing for the node links: collect segments per alpha bucket, then one beginPath/stroke per bucket. Cap devicePixelRatio at 2.

Seed the randomness with a small deterministic PRNG (mulberry32) so the field is identical on every load and can be art-directed rather than re-rolled.

THE TRAP THAT WILL COST YOU AN HOUR

Do not key the "rebuild the particle array" decision on whether the canvas element's width/height attributes changed. React's development double-mount tears down the first instance and starts a second on the same canvas element, which the first already sized, so the second instance sees "size unchanged", never builds a field, and then loops forever drawing nothing over the frozen frame the first one left behind. The effect looks completely static while the render loop runs at full speed, and no error is thrown.

Key the rebuild on whether THIS instance has particles: rebuild if the array is empty, or if the CSS width/height actually changed. Use a ResizeObserver rather than a window resize listener, too, the canvas gets its size from layout, so on first paint clientWidth is often 0 and a window resize never fires to rescue it.

THE NON-NEGOTIABLES

- prefers-reduced-motion: draw exactly one static frame and never start the loop. Skip the pointer parallax entirely rather than damping it, and re-check on change so toggling the OS setting takes effect without a reload.
- IntersectionObserver: pause the loop when the canvas is off-screen. Re-check on visibilitychange so a hidden tab does not render.
- Draw one frame immediately on mount so the canvas is never briefly empty.
- Clean up on unmount: cancel the frame, disconnect both observers, remove every listener.

STRUCTURE

The component fills the box its caller gives it, position absolute, inset 0, and the caller owns the aspect ratio. Mark the wrapper aria-hidden: it is decoration and there is nothing in it for a screen reader.

Performance notes

Technique
Canvas 2D
JavaScript
4.1 KB
Over the wire
2.0 KB gzipped
Dependencies
None
Particles
~4,900 dust + 130 nodes
Device pixel ratio
Capped at 2
Reduced motion
Static frame, no parallax
Hidden tab
Render loop paused