Constellation
A drifting node network on one canvas — every pair closer than the link threshold is stroked, opacity falling off with distance, with optional pointer attraction.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/constellation.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "Constellation" component — a drifting
node network painted on one canvas, used as a hero / auth / section backdrop.
Every pair of nodes closer than a link threshold is stroked, with the line
fading to nothing at the threshold. Its only dependency is a cn() class merger
(clsx + tailwind-merge). No particle library.
Contract
- export function Constellation(props): props extend React.ComponentProps<"div">
(rest props spread onto the root) plus:
- count?: number (default 80) — requested node count. The effective count is
min(count, floor(area / density), 400): an area cap so a small card is not
carpeted, and a 400-node ceiling so a typo cannot freeze the tab.
- density?: number (default 2800, floored at 200) — CSS px^2 of canvas
required per node. Lower is denser.
- linkDistance?: number (default: derived) — link threshold in CSS px.
Omit it and the threshold follows the field's own spacing; pass a number to
pin it; pass 0 to draw no links at all.
- speed?: number (default 1) — multiplier on the drift. 0 freezes the layout
while the pointer keeps working.
- interactive?: boolean (default true) — pointer pulls nearby nodes in and
joins the graph as a temporary node.
- pointerRadius?: number (default 150) — influence disc in CSS px.
- tone?: "foreground" | "primary" | "muted" (default "foreground") — which
semantic token the network is painted with.
- fade?: boolean (default false) — radial alpha mask toward the edges.
- seed?: number (default 5) — integer seed for the layout.
- children render above the canvas; className merges onto the root via cn().
- "use client": canvas, rAF, observers and pointer events.
- Clamp every numeric prop up front and treat a non-finite value as the
default: density 0 asks for an infinite field, a negative speed would run the
drift backwards through the wrap margin, and a NaN threshold would make every
distance comparison false and silently delete the whole network.
Behavior
- DOM: root div "relative isolate overflow-hidden" holding (a) a canvas that is
aria-hidden, pointer-events-none, absolute inset-0 and size-full — the
size-full matters, an absolutely positioned replaced element with inset-0
alone renders at its intrinsic 300x150 — and (b) a "relative z-10" wrapper for
children, so content always sits above the field and the canvas can never
intercept a click. The component paints NO background of its own: the surface
belongs to the consumer, and root flex/grid classes from className position
the children wrapper as usual.
- Deterministic layout: position, heading, drift magnitude, radius and alpha all
come from an integer hash of (nodeId, salt), where nodeId mixes the seed with
the node's index. Address nodes by index rather than pulling from a PRNG
stream: growing the field after a resize then never reshuffles the nodes
already on screen. Math.random() is never called — not during render
(purity/SSR) and not in the loop (screenshots must be reproducible).
- Drift: x += vx * ds each frame with ds = dt * speed and dt clamped to 1/30s,
so a backgrounded tab cannot teleport the field on resume. Nodes wrap
toroidally at the edges with a radius-sized margin; the wrap ASSIGNS an
absolute position instead of adding an offset, so even an absurd speed lands
the node back inside the box.
- Link threshold: the expected number of partners inside radius r is
(n / area) * pi * r^2, so with r = k * sqrt(area / n) the mean degree is
pi * k^2 regardless of container size. k = 1.5 gives ~7 neighbours, i.e. ~3.5
lines per node, which is where the field reads as a network rather than as
dust or as a solid mesh. The derived threshold is that value clamped to
24..180 px — a clamp that must only bite at the extremes (sub-pixel links in a
tiny container, 200px lines across a big sparse hero), never in the normal
range, or it silently breaks the size invariant. A pinned linkDistance
overrides it, and pins the look to one container width. Line alpha is
0.42 * (1 - distance / threshold), so a link is born and dies at zero opacity
and nothing ever pops in.
- Neighbour query: a uniform grid whose cell is max(threshold, mean spacing) —
at least the threshold so every partner in range lives in the node's own cell
or one of the 8 around it, and at least the spacing so a pinned
linkDistance=4 on a wide hero cannot ask for tens of thousands of empty
buckets. Each frame, clear the buckets in place (length = 0, never reallocate),
bucket every node, then for each node scan the tail of its own bucket plus 4
of the 8 neighbouring cells (E, SW, S, SE) — that visits every unordered pair
exactly once. Cost is O(n*k) with k the mean neighbourhood occupancy (~10
candidates at the defaults, since a cell of 1.5 spacings holds ~2.25 nodes)
instead of the O(n^2) sweep.
- Distances are deliberately NOT toroidal even though motion is: two nodes
hugging opposite edges are neighbours on the torus, and stroking that pair
would draw a line straight across the whole container.
- Pointer (interactive): pointermove on the root writes canvas-relative
coordinates into a ref — never state, a trackpad emits far more than 60
events/s and children must not re-render for a decoration. Each frame, a node
inside pointerRadius is displaced along the unit vector toward the cursor by
POINTER_PULL * strength * dt px, with strength = a linear falloff to the rim
TIMES a second fade inside a 26px core, so the cluster orbits the cursor
instead of piling onto one pixel. Use REAL dt here, not dt*speed: slowing the
field down must not make the cursor feel laggy. The pull moves position, not
velocity — no energy accumulates, and the cluster simply disperses back into
its drift when the pointer leaves. The cursor also links out like a node of
the graph, with reach = min(pointerRadius, threshold) so it never reaches
further than the network's own edges do. pointerleave clears the flag.
- Draw order: links first, then nodes, so every line terminates under a disc.
Ink comes from getComputedStyle(canvas).color and is assigned to
fillStyle/strokeStyle verbatim, with all alpha on globalAlpha — never
hand-parse a colour, passing the computed string through means oklch(),
color-mix() and anything else the theme uses just work. Reset globalAlpha to 1
at the end of the frame.
- Sizing: a ResizeObserver observes the canvas itself (not the root, whose
padding would offset the box); its first callback is the initial sizing. Try
observe(canvas, {box: "device-pixel-content-box"}) inside a try/catch —
browsers that do not know that box throw a WebIDL TypeError from observe()
rather than ignoring it — and fall back to observe(canvas). devicePixelRatio
(capped at 2) is the AUTHORITY on scale; the device-pixel box is used only
when it agrees to within 0.01, purely to absorb sub-pixel rounding at
1.25x/1.5x. Emulated and remoted surfaces report a 1:1 device box while the
page renders at 2x, and trusting it there ships a visibly blurry canvas.
Re-apply ctx.setTransform after every resize (writing canvas.width resets the
context) and derive the scale from the actual backing size. Existing nodes are
rescaled by (newW/oldW, newH/oldH), never reseeded, and the grid is rebuilt
once per sizing pass rather than per frame.
- Power: the rAF loop runs only when an IntersectionObserver says the canvas is
on screen, document.visibilityState is "visible", motion is allowed, and there
is something to animate (speed > 0 or interactive). The time base resets when
the loop restarts. A background that burns a core in a hidden tab is a defect.
- Theme flips land as a class/style change on <html>: a MutationObserver
re-reads the ink and, when the loop is paused, repaints the still frame so it
never keeps the old theme's colour.
- prefers-reduced-motion: reduce — read via useSyncExternalStore (server
snapshot false, so it is hydration-safe) and keep it in the effect deps. Under
reduce the loop never starts and the pointer handler returns early: exactly one
frame is painted, and it is a fully composed network — seeded scatter, all
links, theme ink — not a blank box.
- Cleanup on unmount and on every dependency change: cancelAnimationFrame, both
observers, the MutationObserver, and the visibilitychange listener.
Rendering & styling
- Semantic tokens only, zero colour literals: the ink is var(--foreground) /
var(--primary) / var(--muted-foreground), written as an inline color on the
canvas and read back computed, so light/dark and any rebranded palette come
for free. fade uses a mask-image (plus the -webkit- prefix) whose black and
transparent stops are alpha, not rendered colour.
- Merge the consumer className via cn() on the root; the canvas keeps its own
classes.
- Accessibility contract: the canvas is aria-hidden and pointer-events-none, and
the component adds no focusable element and no keyboard surface of its own —
it is decoration, it sits behind content, it never traps scroll or focus, and
the nodes carry no meaning a screen reader should hear. Children stay fully
interactive, selectable and above the field; anything clickable is the
consumer's, wired through their own onClick/href.
Customization levers
- Network feel: AUTO_LINK_K (1.5) is the single knob for how connected the field
looks, and it is honest maths — mean degree is pi * k^2, so 1.2 gives ~4.5
neighbours (sparse constellation) and 1.8 gives ~10 (dense mesh). Prefer it
over a pinned linkDistance when the component must look right at every size;
pin the pixel value when it must match a specific mock.
- Cost, honestly: per frame it is one arc fill per node plus roughly
n * pi * k^2 / 2 strokes — about 3.5 lines per node, so count=200 at the
defaults is ~200 fills and ~700 strokes. Doubling the threshold quadruples the
stroke count, and a pinned threshold past ~3x the mean spacing approaches a
complete graph — at the 400-node ceiling that is ~80k strokes per frame and it
will stutter, so keep pinned values in the 40..200px band. count, density and
MAX_NODES (400) are the three safety valves, and lowering MAX_DPR to 1 halves
fill cost on retina for very large heroes.
- Motion: BASE_DRIFT (18 px/s) is the tempo; the 0.35..1.25 spread on it is how
uneven the field looks. speed=0 with interactive left on gives a frozen
network that still answers the cursor — a good default for text-heavy pages.
- Pointer: POINTER_PULL, pointerRadius and the 26px core radius. Flip the sign
of the pull to scatter instead of gather; drop drawPointerLinks() to keep the
attraction without the cursor joining the graph.
- Palette: extend the tone record with any token — var(--chart-2) for a branded
field, var(--primary-foreground) for an inverted panel. Nodes and links share
one ink by design; give links their own strokeStyle if you want a two-tone
graph, and keep the alpha on globalAlpha either way.
- Weight: node radius range (1.1..2.4 px), LINK_ALPHA (0.42) and ctx.lineWidth
are the visual balance between "dots with hints of lines" and "wireframe".
- Fade: the mask gradient stop (78%) is how early the edges dissolve; swap the
radial gradient for linear-gradient(to bottom, black, transparent) to fade
under a navbar instead.Concepts
- Threshold-linked graph — the only rule in the field is "stroke every pair closer than the threshold", and line alpha is
0.42 · (1 − d / threshold). Because a link is born and dies at exactly zero opacity, edges appear and vanish continuously as nodes drift; nothing ever pops. - Spacing-derived threshold — with radius
k · sqrt(area / n)the mean degree isπ · k²no matter how big the container is, sok = 1.5(≈7 neighbours, ≈3.5 lines per node) reads the same on a 320px card and a full-bleed hero. PinninglinkDistancein pixels overrides it when a mock demands an exact look. - Uniform-grid neighbour query — nodes are bucketed into cells the size of the threshold and each one is compared against its own bucket's tail plus 4 of the 8 neighbouring cells, visiting every pair exactly once:
O(n·k)instead ofO(n²). The buckets are cleared in place every frame rather than reallocated, because per-frame allocation is what kills a full-screen background. - Non-toroidal distances — motion wraps at the edges but linking does not. Two nodes hugging opposite edges are close on the torus, and connecting them would stroke a line straight across the container, which is the classic tell of a naive implementation.
- Cursor as a graph node — the pointer pulls nodes inside its disc with a rim falloff times an inner-core fade, so the cluster orbits instead of collapsing, and it links out to whatever is within the link threshold. The pull displaces position rather than adding velocity, so no energy accumulates and the field disperses back into its drift the moment the pointer leaves.
- Reduced-motion still frame — under
prefers-reduced-motion: reducethe loop never starts and the pointer is ignored, but the one painted frame is a complete network with theme ink, not a blank box. The same frame is what a frozen (speed = 0) or off-screen field shows after a resize.
Fireflies
Wandering points of light on one canvas — each walks its own eased random path and flashes on its own period, with the glow blitted from a sprite baked once.
Lightning
Branching bolts struck at irregular intervals on one canvas — fractal channels with a bright core, a stacked glow, a decaying afterimage and a flash that lifts the whole surface.