Constellation Link
Drifting points that link to their nearest neighbour while it stays in range.
The canvas in this preview is the file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useRef } from "react";
/**
* Vibary · Constellation Link
*
* Points drifting slowly, each drawing a line to its nearest neighbour
* while that neighbour is close enough.
*
* The technique is entirely in the threshold. Two rules make it feel
* alive instead of looking like a fixed graph:
*
* 1. Each point links to its nearest neighbour only, never to everything
* in range. That keeps the graph sparse, and it means the graph
* re-wires — as two points pass each other, a link lets go of one
* partner and takes another, which is the moment the field stops
* looking static.
* 2. The link's opacity is a smoothstep of the distance ratio, so it
* arrives from nothing and leaves to nothing. A hard cutoff at the
* threshold flickers on and off as points jitter around it, and the
* eye reads that as a rendering bug rather than as movement.
*
* The threshold itself is derived from the point density — roughly half
* the mean spacing — so about half the field is linked at any moment
* whatever the container size or the count.
*
* Self-contained: one canvas, no dependencies at all.
* Works with zero props; tune via `count`, `color`, `variant`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ConstellationLinkProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Points in the field. */
count?: number;
/** Point and line color. */
color?: string;
};
type VariantConfig = {
/** Drift speed in px per second. */
drift: number;
/**
* Link range as a multiple of the mean spacing between points. Around
* 0.5 leaves about half the field connected at any moment.
*/
reach: number;
/** Point radius in px. */
dot: number;
};
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Sparse and slow: links come and go over several seconds.
subtle: { drift: 5, reach: 0.5, dot: 1.5 },
// About half the field linked, re-wiring steadily. All-purpose.
default: { drift: 9, reach: 0.62, dot: 1.7 },
// Denser linking and quicker drift, so the graph is visibly restless.
playful: { drift: 15, reach: 0.75, dot: 2 },
};
type Point = {
x: number;
y: number;
vx: number;
vy: number;
scale: number;
};
export default function ConstellationLink({
variant = "default",
count = 38,
color = "#8FA6C8",
}: ConstellationLinkProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
const config = VARIANTS[variant];
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let width = 0;
let height = 0;
let threshold = 0;
const resize = () => {
const rect = canvas.getBoundingClientRect();
const ratio = Math.min(window.devicePixelRatio || 1, 2);
width = rect.width;
height = rect.height;
canvas.width = Math.max(1, Math.floor(width * ratio));
canvas.height = Math.max(1, Math.floor(height * ratio));
context.setTransform(ratio, 0, 0, ratio, 0, 0);
// Range from density, not from the box: the field keeps the same
// character in a wide banner and in a narrow card.
threshold = Math.sqrt((width * height) / Math.max(1, count)) * config.reach;
};
resize();
const random = (min: number, max: number) => min + Math.random() * (max - min);
const spawn = (): Point => {
const heading = random(0, Math.PI * 2);
const speed = config.drift * random(0.5, 1.5);
return {
x: random(0, width),
y: random(0, height),
vx: Math.cos(heading) * speed,
vy: Math.sin(heading) * speed,
scale: random(0.7, 1.3),
};
};
let points = Array.from({ length: count }, spawn);
const nearest = new Int32Array(count);
const distance = new Float64Array(count);
const link = () => {
// O(n²) on a few dozen points is a couple of thousand comparisons
// a frame — cheaper than the bookkeeping a spatial index needs.
for (let index = 0; index < points.length; index++) {
const point = points[index];
let best = -1;
let bestDistance = Infinity;
for (let other = 0; other < points.length; other++) {
if (other === index) continue;
const target = points[other];
const gap = (point.x - target.x) ** 2 + (point.y - target.y) ** 2;
if (gap < bestDistance) {
bestDistance = gap;
best = other;
}
}
nearest[index] = best;
distance[index] = Math.sqrt(bestDistance);
}
};
const render = () => {
context.clearRect(0, 0, width, height);
link();
context.strokeStyle = color;
context.lineWidth = 1;
for (let index = 0; index < points.length; index++) {
const other = nearest[index];
if (other < 0) continue;
// Mutual pairs would otherwise be drawn twice and read brighter
// than the rest for no reason anyone could name.
if (nearest[other] === index && other < index) continue;
const gap = distance[index];
if (gap >= threshold) continue;
// Smoothstep: flat at both ends, so a link appears out of
// nothing and disappears into nothing.
const closeness = 1 - gap / threshold;
const eased = closeness * closeness * (3 - 2 * closeness);
// The links carry the idea, so they are weighted a little above
// the points rather than below them.
context.globalAlpha = eased * 0.55;
context.beginPath();
context.moveTo(points[index].x, points[index].y);
context.lineTo(points[other].x, points[other].y);
context.stroke();
}
context.fillStyle = color;
for (const point of points) {
context.globalAlpha = 0.5;
context.beginPath();
context.arc(point.x, point.y, config.dot * point.scale, 0, Math.PI * 2);
context.fill();
}
context.globalAlpha = 1;
};
// Reduced motion: the graph as it stands. Which points are linked
// and which are alone is the whole picture, and it holds still.
if (reduced) {
render();
const onResizeStill = () => {
resize();
points = Array.from({ length: count }, spawn);
render();
};
window.addEventListener("resize", onResizeStill);
return () => window.removeEventListener("resize", onResizeStill);
}
let frame = 0;
let last = performance.now();
const tick = (now: number) => {
const delta = Math.min((now - last) / 1000, 0.05);
last = now;
for (const point of points) {
point.x += point.vx * delta;
point.y += point.vy * delta;
// Reflect rather than wrap. A wrapped point teleports across the
// frame, and its link snaps with it — one frame of that undoes
// all the care taken over the fades.
if (point.x < 0) {
point.x = 0;
point.vx = -point.vx;
} else if (point.x > width) {
point.x = width;
point.vx = -point.vx;
}
if (point.y < 0) {
point.y = 0;
point.vy = -point.vy;
} else if (point.y > height) {
point.y = height;
point.vy = -point.vy;
}
}
render();
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
const onResize = () => {
resize();
for (const point of points) {
point.x = Math.min(point.x, width);
point.y = Math.min(point.y, height);
}
};
window.addEventListener("resize", onResize);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", onResize);
};
}, [variant, count, color]);
return (
<canvas
ref={canvasRef}
aria-hidden
style={{ width: "100%", height: "100%", display: "block" }}
/>
);
}About this effect
A network background for something about connection — integrations, sync, a team surface. The whole effect lives in the threshold. Each point links to its nearest neighbour only, never to everything in range, which keeps the graph sparse and lets it re-wire: as two points pass each other a link lets go of one partner and takes another, and that is the moment the field stops looking static. The link's opacity is a smoothstep of the distance ratio, so it arrives from nothing and leaves to nothing — a hard cutoff flickers as points jitter around the boundary and reads as a rendering fault. The range is derived from the point density rather than the box, so about half the field is linked whatever the container size or the count.