"use client";
import * as React from "react";
export interface BeamLink {
/** `name` of the node where the beam starts. */
from: string;
/** `name` of the node where the beam ends. */
to: string;
/** Flip this beam's travel direction. */
reverse?: boolean;
}
export interface ConnectionBeamsProps extends React.ComponentPropsWithoutRef<"div"> {
/** Explicit connections. When omitted, every node is connected to the node marked `hub`. */
beams?: BeamLink[];
/** Sideways bend of each beam, in px (negative bends the other way). */
curvature?: number;
/** Time for a light pulse to travel one beam, in ms. */
duration?: number;
/** Delay between beams, in ms. */
stagger?: number;
/** Send the light from the hub outward (or from `to` back to `from`). */
reverse?: boolean;
/** Length of the travelling light as a fraction of the beam (0.05–0.6). */
beamLength?: number;
/** Stroke width of the beams, in px. */
pathWidth?: number;
/** Colour at the start of each beam. */
fromColor?: string;
/** Colour at the end of each beam. */
toColor?: string;
/** Render a screen-reader list of the connections (uses each node's `label`). */
describe?: boolean;
}
export interface BeamNodeProps extends React.ComponentPropsWithoutRef<"div"> {
/** Unique key used by `beams` (not rendered as a DOM id). */
name: string;
/** Marks the hub that every other node connects to by default. */
hub?: boolean;
/** Human-readable name used in the screen-reader description of the connections. */
label?: string;
}
interface NodeEntry {
el: HTMLElement;
hub: boolean;
label?: string;
}
interface BeamPath {
key: string;
d: string;
x1: number;
y1: number;
x2: number;
y2: number;
fromLabel: string;
toLabel: string;
}
interface BeamsContextValue {
register: (name: string, entry: NodeEntry) => () => void;
}
const BeamsContext = React.createContext<BeamsContextValue | null>(null);
/* ----------------------------- Reduced motion ------------------------------ */
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* -------------------------------- Geometry --------------------------------- */
const round = (n: number) => Math.round(n * 10) / 10;
/** Quadratic curve between two points, bent sideways by `curvature` px. */
function curve(x1: number, y1: number, x2: number, y2: number, curvature: number) {
const dx = x2 - x1;
const dy = y2 - y1;
const len = Math.hypot(dx, dy) || 1;
const cx = (x1 + x2) / 2 + (-dy / len) * curvature;
const cy = (y1 + y2) / 2 + (dx / len) * curvature;
return `M ${round(x1)} ${round(y1)} Q ${round(cx)} ${round(cy)} ${round(x2)} ${round(y2)}`;
}
/** Comet layers: tail → head. Each is a dash of a fraction of the full length. */
const LAYERS = [
{ length: 1, opacity: 0.22, width: 1 },
{ length: 0.55, opacity: 0.5, width: 1 },
{ length: 0.18, opacity: 1, width: 1.35 },
];
/* -------------------------------- Component -------------------------------- */
/**
* Animated SVG beams between `BeamNode`s inside a container. Node centres are measured with
* ResizeObserver, joined by gently curved quadratic paths, and a comet of light travels each
* path by animating `stroke-dashoffset` on a normalised `pathLength` (Web Animations API), so
* pulses take the same time on short and long beams. Animations pause offscreen and in hidden
* tabs; reduced motion draws static gradient lines.
*/
export function ConnectionBeams({
beams,
curvature = 40,
duration = 2800,
stagger = 450,
reverse = false,
beamLength = 0.3,
pathWidth = 1.5,
fromColor = "#FF7448",
toColor = "#B6F3D1",
describe = true,
className = "",
children,
...props
}: ConnectionBeamsProps) {
const reduceMotion = usePrefersReducedMotion();
const uid = React.useId().replace(/:/g, "");
const containerRef = React.useRef<HTMLDivElement>(null);
const svgRef = React.useRef<SVGSVGElement>(null);
const nodesRef = React.useRef(new Map<string, NodeEntry>());
const hooksRef = React.useRef<{ schedule: () => void; observe: (el: Element) => void; unobserve: (el: Element) => void } | null>(null);
const [paths, setPaths] = React.useState<BeamPath[]>([]);
const register = React.useCallback((name: string, entry: NodeEntry) => {
nodesRef.current.set(name, entry);
hooksRef.current?.observe(entry.el);
hooksRef.current?.schedule();
return () => {
if (nodesRef.current.get(name)?.el === entry.el) nodesRef.current.delete(name);
hooksRef.current?.unobserve(entry.el);
hooksRef.current?.schedule();
};
}, []);
const context = React.useMemo(() => ({ register }), [register]);
// Serialised so an inline `beams` array does not re-run the effect on every render.
const beamsKey = beams ? JSON.stringify(beams) : "";
React.useEffect(() => {
const container = containerRef.current;
if (!container) return;
const links: BeamLink[] | null = beamsKey ? (JSON.parse(beamsKey) as BeamLink[]) : null;
let raf = 0;
let lastSignature = "";
const measure = () => {
raf = 0;
const box = container.getBoundingClientRect();
const nodes = nodesRef.current;
const centre = (el: HTMLElement) => {
const r = el.getBoundingClientRect();
return [r.left + r.width / 2 - box.left, r.top + r.height / 2 - box.top] as const;
};
let list = links;
if (!list) {
const hub = [...nodes.entries()].find(([, n]) => n.hub)?.[0];
list = hub ? [...nodes.keys()].filter((k) => k !== hub).map((k) => ({ from: k, to: hub })) : [];
}
const next: BeamPath[] = [];
for (const link of list) {
const a = nodes.get(link.from);
const b = nodes.get(link.to);
if (!a || !b) continue;
const flip = reverse !== Boolean(link.reverse);
const [ax, ay] = centre(flip ? b.el : a.el);
const [bx, by] = centre(flip ? a.el : b.el);
next.push({
key: `${link.from}->${link.to}`,
d: curve(ax, ay, bx, by, flip ? -curvature : curvature),
x1: round(ax),
y1: round(ay),
x2: round(bx),
y2: round(by),
fromLabel: a.label ?? link.from,
toLabel: b.label ?? link.to,
});
}
const signature = next.map((p) => p.key + p.d + p.fromLabel + p.toLabel).join("|");
if (signature !== lastSignature) {
lastSignature = signature;
setPaths(next);
}
};
const schedule = () => {
if (!raf) raf = requestAnimationFrame(measure);
};
const ro = new ResizeObserver(schedule);
ro.observe(container);
for (const n of nodesRef.current.values()) ro.observe(n.el);
hooksRef.current = { schedule, observe: (el) => ro.observe(el), unobserve: (el) => ro.unobserve(el) };
schedule();
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
hooksRef.current = null;
};
}, [beamsKey, curvature, reverse]);
// Travelling light: one infinite animation per comet layer, re-created when beams change.
const pathsKey = paths.map((p) => p.key).join("|");
React.useEffect(() => {
const svg = svgRef.current;
if (!svg || reduceMotion || !pathsKey) return;
const L = Math.min(0.6, Math.max(0.05, beamLength));
const animations: Animation[] = [];
svg.querySelectorAll<SVGPathElement>("[data-beam-layer]").forEach((el) => {
const index = Number(el.dataset.beamIndex);
const len = L * Number(el.dataset.beamLayer);
// Head travels from 0 to 1 + L so the whole tail leaves the path before repeating.
el.style.strokeDasharray = `${len} ${1 + L}`;
animations.push(
el.animate([{ strokeDashoffset: `${len}` }, { strokeDashoffset: `${len - 1 - L}` }], {
duration: Math.max(200, duration),
delay: index * Math.max(0, stagger),
iterations: Infinity,
easing: "cubic-bezier(0.45, 0.05, 0.35, 1)",
fill: "backwards",
}),
);
});
let inView = true;
const sync = () => {
const run = inView && !document.hidden;
for (const a of animations) {
if (run) a.play();
else a.pause();
}
};
const io = new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting;
sync();
});
io.observe(svg);
document.addEventListener("visibilitychange", sync);
return () => {
io.disconnect();
document.removeEventListener("visibilitychange", sync);
for (const a of animations) a.cancel();
};
}, [pathsKey, duration, stagger, beamLength, reduceMotion]);
return (
<BeamsContext.Provider value={context}>
<div ref={containerRef} className={`relative ${className}`} {...props}>
<svg
ref={svgRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full overflow-visible"
fill="none"
>
<defs>
{paths.map((p, i) => (
<linearGradient key={p.key} id={`${uid}-g${i}`} gradientUnits="userSpaceOnUse" x1={p.x1} y1={p.y1} x2={p.x2} y2={p.y2}>
<stop offset="0" stopColor={fromColor} />
<stop offset="1" stopColor={toColor} />
</linearGradient>
))}
</defs>
{paths.map((p, i) => (
<g key={p.key}>
<path
d={p.d}
strokeWidth={pathWidth}
strokeLinecap="round"
className="stroke-zinc-900/10 dark:stroke-white/10"
/>
{reduceMotion ? (
<path d={p.d} stroke={`url(#${uid}-g${i})`} strokeWidth={pathWidth} strokeLinecap="round" opacity={0.55} />
) : (
LAYERS.map((layer) => (
<path
key={layer.length}
data-beam-layer={layer.length}
data-beam-index={i}
d={p.d}
pathLength={1}
stroke={`url(#${uid}-g${i})`}
strokeWidth={pathWidth * layer.width}
strokeLinecap="round"
strokeDasharray="0 2"
opacity={layer.opacity}
/>
))
)}
</g>
))}
</svg>
{children}
{describe && paths.length > 0 && (
<ul className="sr-only">
{paths.map((p) => (
<li key={p.key}>{`${p.fromLabel} connects to ${p.toLabel}`}</li>
))}
</ul>
)}
</div>
</BeamsContext.Provider>
);
}
/** A measured endpoint for beams. Give it a background so beams appear to emerge from its edge. */
export function BeamNode({ name, hub = false, label, className = "", children, ...props }: BeamNodeProps) {
const ctx = React.useContext(BeamsContext);
const setRef = React.useCallback(
(el: HTMLDivElement | null) => {
if (!el || !ctx) return;
return ctx.register(name, { el, hub, label });
},
[ctx, name, hub, label],
);
return (
<div
ref={setRef}
data-hub={hub || undefined}
className={`relative z-10 flex items-center justify-center rounded-2xl border border-zinc-900/10 bg-white text-zinc-700 shadow-[0_1px_2px_rgba(0,0,0,0.05)] dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-300 ${className}`}
{...props}
>
{children}
</div>
);
}