Backgrounds

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/** Which semantic token the nodes and links are painted with. */
export type ConstellationTone = "foreground" | "primary" | "muted"

/**
 * Ink per tone — semantic tokens only. The value is written onto the canvas as
 * an inline `color`, then the *computed* string is read back and handed to the
 * 2D context verbatim. Any syntax the browser resolves (oklch(), color-mix(),
 * a brand colour the consumer parked behind the token) works, and nothing here
 * ever parses or hard-codes a colour.

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/constellation.json

Prompt

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, so k = 1.5 (≈7 neighbours, ≈3.5 lines per node) reads the same on a 320px card and a full-bleed hero. Pinning linkDistance in 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 of O(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: reduce the 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.

On This Page