Constellation
Nodes and links, rebuilt to the standard: nodes deflect off measured boxes and every link is clipped exactly at the boundary.
Install
$ npx shadcn@latest add wisp.pouriah.com/r/constellation.jsonBackdrop · canvasobstacle-aware
Links never cross your headline.
measured, not guessed
"use client";
import * as React from "react";
import {
AVOID_SELECTOR,
cx,
DEFAULT_LANTERN,
DEFAULT_WISP,
deflect,
hsl,
inAnyBox,
segmentHitsAny,
useCanvasScene,
useObstacles,
useTokenColor,
useWispGain,
type Hsl,
} from "@pouriahlabs/wisp-ui";
/**
* Constellation — the familiar nodes-and-links field, rebuilt to the standard.
*
* The effect itself is everywhere. What isn't: nodes deflect off measured
* content boxes instead of drifting under them, and every link is tested
* against those boxes with an exact segment clip, so a line is never drawn
* across a word. A sampled test misses short crossings near a corner, and a
* link flickering through the edge of a headline is precisely the artefact this
* library exists to prevent.
*/
export interface ConstellationProps extends React.HTMLAttributes<HTMLDivElement> {
fromToken?: string;
toToken?: string;
/** Selector for content nodes must not cross. */
avoid?: string;
/** Horizontal breathing room around measured content, CSS px. */
padX?: number;
/** Vertical breathing room around measured content, CSS px. */
padY?: number;
/** Roughly one node per this much area, px². */
density?: number;
/** Link nodes closer than this, px. */
linkDistance?: number;
/** Fraction of nodes drawn in the `toToken` accent. */
accentRatio?: number;
/** Drift-speed multiplier. `1` is the default pace. */
speed?: number;
/** Draw the measured boxes as wireframes. Debugging aid — and a good demo. */
debugBoxes?: boolean;
}
interface Node {
x: number;
y: number;
vx: number;
vy: number;
radius: number;
accent: boolean;
}
export function Constellation({
fromToken = "--wisp",
toToken = "--lantern",
avoid = AVOID_SELECTOR,
padX = 8,
padY = 6,
density = 3400,
linkDistance = 90,
accentRatio = 0.22,
speed = 1,
debugBoxes = false,
className,
...props
}: ConstellationProps) {
const ref = React.useRef<HTMLCanvasElement>(null);
const boxes = useObstacles(ref, avoid, { padX, padY });
const base = useTokenColor(fromToken, DEFAULT_WISP);
const accent = useTokenColor(toToken, DEFAULT_LANTERN);
const gain = useWispGain();
const nodes = React.useRef<Node[]>([]);
// Each node's glow is one radial gradient; allocating ~90 of them per frame is
// the scene's dominant cost. Bake one sprite per colour instead and stamp it
// with `drawImage`, rebuilding only when a theme swap moves the tokens.
const sprites = React.useRef<{ key: string; base: HTMLCanvasElement; accent: HTMLCanvasElement } | null>(
null,
);
useCanvasScene(ref, {
setup: ({ width, height }) => {
// The cap is a cost ceiling, not a look: linking is O(n²), and ~90 nodes
// is ~4k pair tests a frame, which stays cheap. Below it, area decides —
// so a card, a hero and a full-screen view all read at the same density.
const count = Math.min(90, Math.max(12, Math.round((width * height) / density)));
nodes.current = Array.from({ length: count }, () =>
spawn(width, height, accentRatio, boxes.current),
);
},
draw: ({ ctx, width, height, dt }) => {
ctx.clearRect(0, 0, width, height);
const maxDistanceSq = linkDistance * linkDistance;
for (const node of nodes.current) {
node.x += node.vx * dt * speed;
node.y += node.vy * dt * speed;
if (node.x < 4) {
node.x = 4;
node.vx = Math.abs(node.vx);
} else if (node.x > width - 4) {
node.x = width - 4;
node.vx = -Math.abs(node.vx);
}
if (node.y < 4) {
node.y = 4;
node.vy = Math.abs(node.vy);
} else if (node.y > height - 4) {
node.y = height - 4;
node.vy = -Math.abs(node.vy);
}
// Deflect off measured content along the shortest axis out.
for (const box of boxes.current) {
if (node.x <= box.l || node.x >= box.r || node.y <= box.t || node.y >= box.b) continue;
const out = deflect(box, node.x, node.y);
node.x = out.x;
node.y = out.y;
if (out.axis === "x") node.vx = out.sign * Math.abs(node.vx);
else node.vy = out.sign * Math.abs(node.vy);
break;
}
}
// Links, clipped at every content boundary.
ctx.lineWidth = 1;
for (let i = 0; i < nodes.current.length; i++) {
const a = nodes.current[i]!;
for (let j = i + 1; j < nodes.current.length; j++) {
const b = nodes.current[j]!;
const dx = a.x - b.x;
const dy = a.y - b.y;
const distanceSq = dx * dx + dy * dy;
if (distanceSq > maxDistanceSq) continue;
if (segmentHitsAny(boxes.current, a.x, a.y, b.x, b.y)) continue;
const alpha = (1 - Math.sqrt(distanceSq) / linkDistance) * 0.42 * gain;
ctx.strokeStyle = hsl(base, base[2], alpha);
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
}
ctx.globalCompositeOperation = "lighter";
const key = `${base.join()}|${accent.join()}`;
if (!sprites.current || sprites.current.key !== key) {
sprites.current = { key, base: glowSprite(base), accent: glowSprite(accent) };
}
for (const node of nodes.current) {
const sprite = node.accent ? sprites.current.accent : sprites.current.base;
const radius = node.radius * 4;
// Alpha and radius were baked at 1; scale and fade per node here.
ctx.globalAlpha = 0.85 * gain;
ctx.drawImage(sprite, node.x - radius, node.y - radius, radius * 2, radius * 2);
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "source-over";
if (debugBoxes) {
ctx.save();
ctx.lineWidth = 1;
ctx.font = "600 9px ui-monospace, SFMono-Regular, Menlo, monospace";
for (const box of boxes.current) {
ctx.setLineDash([4, 4]);
ctx.strokeStyle = hsl(accent, accent[2], 0.85);
ctx.fillStyle = hsl(accent, accent[2], 0.05);
ctx.fillRect(box.l, box.t, box.r - box.l, box.b - box.t);
ctx.strokeRect(box.l + 0.5, box.t + 0.5, box.r - box.l - 1, box.b - box.t - 1);
ctx.setLineDash([]);
ctx.fillStyle = hsl(accent, accent[2], 0.95);
ctx.fillText(avoid, box.l + 4, box.t - 4);
}
ctx.restore();
}
},
});
return (
<div
aria-hidden
className={cx("pointer-events-none absolute inset-0 overflow-hidden", className)}
{...props}
>
<canvas ref={ref} className="absolute inset-0 h-full w-full" />
</div>
);
}
/** Radius the glow is baked at. Nodes are far smaller, so it only ever scales
* down, where bilinear sampling stays clean. */
const GLOW_RADIUS = 48;
/** Bake a node's radial glow — bright core to transparent edge — into an
* offscreen canvas once, at full alpha, so the draw loop can stamp it. */
function glowSprite(color: Hsl): HTMLCanvasElement {
const canvas = document.createElement("canvas");
canvas.width = canvas.height = GLOW_RADIUS * 2;
const g = canvas.getContext("2d")!;
const grad = g.createRadialGradient(
GLOW_RADIUS,
GLOW_RADIUS,
0,
GLOW_RADIUS,
GLOW_RADIUS,
GLOW_RADIUS,
);
grad.addColorStop(0, hsl(color, color[2] + 24, 1));
grad.addColorStop(1, hsl(color, color[2], 0));
g.fillStyle = grad;
g.fillRect(0, 0, GLOW_RADIUS * 2, GLOW_RADIUS * 2);
return canvas;
}
function spawn(
width: number,
height: number,
accentRatio: number,
boxes: ReadonlyArray<{ l: number; r: number; t: number; b: number }>,
): Node {
let x = 0;
let y = 0;
// Never seed a node inside content; give up after a bounded search so a
// layout with almost no free space can't spin here.
for (let tries = 0; tries < 24; tries++) {
x = Math.random() * width;
y = Math.random() * height;
if (!inAnyBox(boxes, x, y)) break;
}
return {
x,
y,
vx: (Math.random() - 0.5) * 20,
vy: (Math.random() - 0.5) * 20,
radius: 1.1 + Math.random() * 1.5,
accent: Math.random() < accentRatio,
};
}
Certified against the contract
- ✓Reduced motion paints one composed still frame — never a blank box
- ✓Pauses off-screen and in hidden tabs
- ✓Re-reads design tokens when the theme changes
- ✓SSR-safe: no hydration mismatch, no layout shift
- ✓Decorative layers are aria-hidden and pointer-events-none
- ✓Device pixel ratio clamped
- ✓Zero runtime dependencies beyond React