animatedBeam
A Effects block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call animatedBeam() with no arguments for a working demo, or edit the code below live.
Props
| Prop | Type | Description |
|---|---|---|
nodes | AnimatedBeamNode[] | Badge nodes placed inside the canvas. Defaults to a 2-source/1-hub layout. |
connections | AnimatedBeamConnection[] | Animated connections between node ids. Defaults to two beams converging on the hub. |
height | number | Canvas height in pixels. Defaults to 260. |
Implementation notes
Default demo renders 3 badge nodes (2 sources + 1 hub) with 2 quadratic-curve beams converging on the hub; nodes/connections are fully overridable via props. Path geometry is measured at runtime via getBoundingClientRect (container + node refs captured through _onMount closures, matching the framework's documented refs pattern) and recomputed on ResizeObserver + window resize/scroll. The traveling glow is realized by animating a <linearGradient>'s userSpaceOnUse x1/y1/x2/y2 as a short moving window interpolated along the straight chord between the two node centers (driven by a plain requestAnimationFrame loop, per the task's guidance to use rAF for SVG path loops instead of CSS keyframes for this kind of JS-measured effect) rather than literally sampling points along the rendered curve via SVGGeometryElement.getPointAtLength — a deliberate simplification (that browser API plus rAF/ResizeObserver are all absent in jsdom, so the component guards every browser-only API and no-ops cleanly in that environment, verified by the 'removes cleanly' test). For modest curvature values this reads visually the same as a curve-following pulse. All SVG <stop> elements carry _doctorDisable:'missing-color' since color has no meaning on a non-text paint-server node; @domphy/doctor diagnose() reports zero issues on the default tree.
Status: ported · Reference: Magic UI original
// Magic UI "Animated Beam" — clean-room reimplementation.
//
// A glowing SVG line that pulses along a curved (or straight) path connecting
// two circular badge nodes, commonly used to visualize data/connection flow
// between icons in an architecture diagram. The path is measured at runtime
// from the real DOM positions of the node badges (so it stays anchored when
// the layout shifts) and the bright segment loops continuously along it.
//
// Implemented purely from the block's public functional/visual spec — no
// upstream Magic UI source was viewed or copied.
import type { DomphyElement, ElementNode } from "@domphy/core";
import { behavior } from "@domphy/core";
import type { ThemeColor } from "@domphy/theme";
import { themeColor, themeSpacing } from "@domphy/theme";
/** A circular badge node placed inside the diagram canvas. */
export interface AnimatedBeamNode {
/** Stable identifier referenced by `connections[].from`/`to`. */
id: string;
/** Content rendered inside the badge (icon/glyph). Defaults to a themed dot. */
content?: DomphyElement;
/** Vertical center of the badge, as a CSS position (e.g. `"20%"`). */
top: string;
/** Horizontal center of the badge, as a CSS position (e.g. `"14%"`). */
left: string;
/** Accent color used by the default glyph when `content` is omitted. */
accentColor?: ThemeColor;
}
/** One animated connection between two nodes. */
export interface AnimatedBeamConnection {
/** `id` of the source node. */
from: string;
/** `id` of the target node. */
to: string;
/** Bow amount in pixels. `0` renders a straight line. Defaults to `40`. */
curvature?: number;
/** Axis the curve bows along. Defaults to `"vertical"`. */
bend?: "vertical" | "horizontal";
/** Plays the pulse from `to` towards `from` instead of `from` towards `to`. */
reverse?: boolean;
/** Full loop duration in ms. Defaults to `5000`. */
duration?: number;
/** Delay in ms before this beam's pulse starts — use to stagger multiple beams. Defaults to `0`. */
delay?: number;
/** Leading color of the traveling glow gradient. Defaults to `"#ffaa40"` (orange). */
gradientStartColor?: string;
/** Trailing color of the traveling glow gradient. Defaults to `"#9c40ff"` (purple). */
gradientStopColor?: string;
/** Theme color of the static background line. Defaults to `"neutral"`. */
pathColor?: ThemeColor;
/** Stroke width (px) of the static background line (and the glow, which matches it). Defaults to `2`. */
pathWidth?: number;
/** Opacity of the static background line. Defaults to `0.35` (upstream's 0.2 over its literal `gray` stroke reads ~#e6e6e6; the darker shift-4 token below needs the higher alpha to land on the same visible light gray). */
pathOpacity?: number;
/** Extra x/y offset (px) applied to the beam's start point, past the node's edge. */
startXOffset?: number;
startYOffset?: number;
/** Extra x/y offset (px) applied to the beam's end point, past the node's edge. */
endXOffset?: number;
endYOffset?: number;
}
export interface AnimatedBeamProps {
/** Badge nodes placed inside the canvas. Defaults to a 2-source/1-hub layout. */
nodes?: AnimatedBeamNode[];
/** Animated connections between node ids. Defaults to two beams converging on the hub. */
connections?: AnimatedBeamConnection[];
/** Canvas height in pixels. Defaults to `260`. */
height?: number;
}
let animatedBeamInstanceCounter = 0;
/** Small themed dot used as the default badge glyph when a node has no custom content. */
function defaultBeamGlyph(color: ThemeColor): DomphyElement {
return {
svg: [{ circle: null, cx: "12", cy: "12", r: "5" }],
width: "18",
height: "18",
viewBox: "0 0 24 24",
xmlns: "http://www.w3.org/2000/svg",
fill: "currentColor",
style: { color: (listener) => themeColor(listener, "shift-9", color) },
} as DomphyElement;
}
/** One circular badge node, absolutely positioned inside the shared canvas. */
function beamNodeElement(
nodeSpec: AnimatedBeamNode,
registerNode: (id: string, element: HTMLElement | null) => void,
): DomphyElement {
return {
div: [
nodeSpec.content ?? defaultBeamGlyph(nodeSpec.accentColor ?? "primary"),
],
_key: nodeSpec.id,
...behavior<{
id: string;
registerNode: (id: string, element: HTMLElement | null) => void;
}>(
`magicui-animated-beam-node-${nodeSpec.id}`,
(node, props) => {
props.registerNode(props.id, node.domElement as HTMLElement);
return {
update() {},
destroy() {
props.registerNode(props.id, null);
},
};
},
{ id: nodeSpec.id, registerNode },
),
style: {
position: "absolute",
top: nodeSpec.top,
left: nodeSpec.left,
transform: "translate(-50%, -50%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
width: themeSpacing(12),
height: themeSpacing(12),
borderRadius: "50%",
backgroundColor: (listener) => themeColor(listener, "inherit", "neutral"),
color: (listener) => themeColor(listener, "shift-9", "neutral"),
outline: (listener) =>
`1px solid ${themeColor(listener, "shift-3", "neutral")}`,
outlineOffset: "-1px",
boxShadow: (listener) =>
`0 ${themeSpacing(1)} ${themeSpacing(4)} ${themeColor(listener, "shift-3", "neutral")}`,
zIndex: 1,
},
} as DomphyElement;
}
/**
* A glowing SVG beam that pulses along a curved path between two badge nodes,
* visualizing connection/data flow (e.g. in an architecture diagram). Call
* with no arguments for a working two-source/one-hub demo; the path geometry
* is measured from the real DOM positions of the nodes on mount and
* recomputed on resize.
*/
function animatedBeam(props: AnimatedBeamProps = {}): DomphyElement<"div"> {
const instanceId = ++animatedBeamInstanceCounter;
const nodes: AnimatedBeamNode[] = props.nodes ?? [
{ id: "source-a", top: "18%", left: "14%", accentColor: "info" },
{ id: "source-b", top: "82%", left: "14%", accentColor: "success" },
{ id: "hub", top: "50%", left: "84%", accentColor: "primary" },
];
const connections: AnimatedBeamConnection[] = props.connections ?? [
{ from: "source-a", to: "hub", curvature: 60, duration: 3200 },
{ from: "source-b", to: "hub", curvature: -60, duration: 3200, delay: 900 },
];
const height = props.height ?? 260;
const nodeElements = new Map<string, HTMLElement>();
const registerNode = (id: string, element: HTMLElement | null) => {
if (element) nodeElements.set(id, element);
else nodeElements.delete(id);
};
interface BeamRuntime {
staticPathElement: SVGPathElement | null;
glowPathElement: SVGPathElement | null;
gradientElement: SVGLinearGradientElement | null;
startX: number;
startY: number;
endX: number;
endY: number;
hasGeometry: boolean;
}
const runtimes: BeamRuntime[] = connections.map(() => ({
staticPathElement: null,
glowPathElement: null,
gradientElement: null,
startX: 0,
startY: 0,
endX: 0,
endY: 0,
hasGeometry: false,
}));
let containerElement: HTMLElement | null = null;
let svgElement: SVGSVGElement | null = null;
let svgWidth = 0;
let resizeObserver: ResizeObserver | null = null;
let recomputeFrameId: number | null = null;
let animationFrameId: number | null = null;
let animationStart: number | null = null;
let removeWindowListeners: (() => void) | null = null;
function quadraticPathData(
startX: number,
startY: number,
endX: number,
endY: number,
curvature: number,
bend: "vertical" | "horizontal",
): string {
const midX = (startX + endX) / 2;
const midY = (startY + endY) / 2;
// Upstream anchors the control point to the start point, not the chord
// midpoint: controlY = startY - curvature (animated-beam.tsx:90). The
// horizontal-bend variant is the port's own symmetric analog (startX-anchored).
const controlX = bend === "horizontal" ? startX + curvature : midX;
const controlY = bend === "horizontal" ? midY : startY - curvature;
return `M ${startX} ${startY} Q ${controlX} ${controlY} ${endX} ${endY}`;
}
function recompute(): void {
if (!containerElement) return;
const containerRect = containerElement.getBoundingClientRect();
if (containerRect.width === 0 && containerRect.height === 0) return;
svgWidth = containerRect.width;
svgElement?.setAttribute(
"viewBox",
`0 0 ${containerRect.width} ${containerRect.height}`,
);
connections.forEach((connection, index) => {
const runtime = runtimes[index];
const fromElement = nodeElements.get(connection.from);
const toElement = nodeElements.get(connection.to);
if (!fromElement || !toElement || !runtime.staticPathElement) {
runtime.hasGeometry = false;
return;
}
const fromRect = fromElement.getBoundingClientRect();
const toRect = toElement.getBoundingClientRect();
runtime.startX =
fromRect.left -
containerRect.left +
fromRect.width / 2 +
(connection.startXOffset ?? 0);
runtime.startY =
fromRect.top -
containerRect.top +
fromRect.height / 2 +
(connection.startYOffset ?? 0);
runtime.endX =
toRect.left -
containerRect.left +
toRect.width / 2 +
(connection.endXOffset ?? 0);
runtime.endY =
toRect.top -
containerRect.top +
toRect.height / 2 +
(connection.endYOffset ?? 0);
runtime.hasGeometry = true;
const d = quadraticPathData(
runtime.startX,
runtime.startY,
runtime.endX,
runtime.endY,
connection.curvature ?? 40,
connection.bend ?? "vertical",
);
runtime.staticPathElement.setAttribute("d", d);
runtime.glowPathElement?.setAttribute("d", d);
});
}
// The gradient vector is a fixed ~10%-of-viewport-wide band whose stops run
// orange -> purple, swept horizontally across the whole SVG viewport. Upstream
// (animated-beam.tsx:56-68) keeps y1==y2=='0%' and only animates x as a
// percentage of the viewport width, so the pulse always travels left-to-right
// (or right-to-left when reversed) regardless of the beam's actual angle — it
// does NOT follow the start->end chord.
// easeOutExpo (https://easings.net/#easeOutExpo) — matches upstream's
// motion/react transition easing ([0.16, 1, 0.3, 1]) for the sweep.
function easeOutExpo(t: number): number {
return t >= 1 ? 1 : 1 - 2 ** (-10 * t);
}
function tick(timestamp: number): void {
// Belt-and-suspenders stop condition: some hosts (e.g. a test harness
// that wipes the DOM directly instead of going through the framework's
// removal lifecycle) never fire the `_onRemove` hook below. Bailing here
// once the container is detached prevents this loop from leaking
// forever across later, unrelated test files.
if (!containerElement || !containerElement.isConnected) return;
if (animationStart === null) animationStart = timestamp;
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
connections.forEach((connection, index) => {
const runtime = runtimes[index];
const gradient = runtime.gradientElement;
if (!gradient || !runtime.hasGeometry) return;
const duration = connection.duration ?? 5000;
const delay = connection.delay ?? 0;
const elapsed = timestamp - animationStart! - delay;
if (elapsed < 0) return;
const progress = easeOutExpo((elapsed % duration) / duration);
// Upstream keyframes (fractions of viewport width): non-reverse sweeps
// x1 10%->110% / x2 0%->100%; reverse sweeps x1 90%->-10% / x2 100%->0%.
const x1Fraction = connection.reverse
? lerp(0.9, -0.1, progress)
: lerp(0.1, 1.1, progress);
const x2Fraction = connection.reverse
? lerp(1.0, 0.0, progress)
: lerp(0.0, 1.0, progress);
gradient.setAttribute("x1", String(x1Fraction * svgWidth));
gradient.setAttribute("x2", String(x2Fraction * svgWidth));
gradient.setAttribute("y1", "0");
gradient.setAttribute("y2", "0");
});
animationFrameId = window.requestAnimationFrame(tick);
}
function gradientId(index: number): string {
return `domphy-animated-beam-${instanceId}-${index}`;
}
function staticPathElement(
connection: AnimatedBeamConnection,
index: number,
): DomphyElement {
return {
path: null,
d: "M 0 0",
fill: "none",
stroke: "currentColor",
strokeWidth: String(connection.pathWidth ?? 2),
strokeLinecap: "round",
_key: `static-${index}`,
...behavior(
`magicui-animated-beam-static-${index}`,
(node) => {
runtimes[index].staticPathElement =
node.domElement as unknown as SVGPathElement;
return {
update() {},
destroy() {
runtimes[index].staticPathElement = null;
},
};
},
{},
),
style: {
color: (listener) =>
themeColor(listener, "shift-4", connection.pathColor ?? "neutral"),
opacity: connection.pathOpacity ?? 0.35,
},
} as DomphyElement;
}
function glowPathElement(
connection: AnimatedBeamConnection,
index: number,
): DomphyElement {
return {
path: null,
d: "M 0 0",
fill: "none",
stroke: `url(#${gradientId(index)})`,
// Upstream draws the glow at the SAME width as the static path
// (animated-beam.tsx:141 & :147 both use pathWidth).
strokeWidth: String(connection.pathWidth ?? 2),
strokeLinecap: "round",
_key: `glow-${index}`,
...behavior(
`magicui-animated-beam-glow-${index}`,
(node) => {
runtimes[index].glowPathElement =
node.domElement as unknown as SVGPathElement;
return {
update() {},
destroy() {
runtimes[index].glowPathElement = null;
},
};
},
{},
),
} as DomphyElement;
}
function gradientDefinition(
connection: AnimatedBeamConnection,
index: number,
): DomphyElement {
const startColor = connection.gradientStartColor ?? "#ffaa40";
const stopColor = connection.gradientStopColor ?? "#9c40ff";
// `<stop>` is a paint-server node, not text — it has no `color` to follow the
// tone context, so the `missing-color` doctor rule is a false positive here.
const stop = (
offset: string,
color: string,
opacity: number,
): DomphyElement =>
({
stop: null,
offset,
style: { stopColor: color, stopOpacity: opacity },
_doctorDisable: "missing-color",
}) as DomphyElement;
// Upstream's 4 asymmetric stops (animated-beam.tsx:177-184): start color at
// 0% transitioning to stop color, opaque middle, fading to transparent at 100%.
return {
linearGradient: [
stop("0%", startColor, 0),
stop("0%", startColor, 1),
stop("32.5%", stopColor, 1),
stop("100%", stopColor, 0),
],
id: gradientId(index),
gradientUnits: "userSpaceOnUse",
x1: "0",
y1: "0",
x2: "0",
y2: "0",
_key: `gradient-${index}`,
...behavior(
`magicui-animated-beam-gradient-${index}`,
(node) => {
runtimes[index].gradientElement =
node.domElement as unknown as SVGLinearGradientElement;
return {
update() {},
destroy() {
runtimes[index].gradientElement = null;
},
};
},
{},
),
} as DomphyElement;
}
const svgChildren: DomphyElement[] = [
{
defs: connections.map((connection, index) =>
gradientDefinition(connection, index),
),
} as DomphyElement,
...connections.flatMap((connection, index) => [
staticPathElement(connection, index),
glowPathElement(connection, index),
]),
];
return {
div: [
...nodes.map((nodeSpec) => beamNodeElement(nodeSpec, registerNode)),
{
svg: svgChildren,
width: "100%",
height: "100%",
xmlns: "http://www.w3.org/2000/svg",
ariaHidden: "true",
...behavior(
"magicui-animated-beam-svg",
(node) => {
svgElement = node.domElement as unknown as SVGSVGElement;
return {
update() {},
destroy() {
svgElement = null;
},
};
},
{},
),
style: { position: "absolute", inset: 0, pointerEvents: "none" },
} as DomphyElement,
],
...behavior(
"magicui-animated-beam",
(node: ElementNode) => {
containerElement = node.domElement as HTMLElement;
if (
typeof window === "undefined" ||
typeof window.requestAnimationFrame !== "function"
) {
return { update() {}, destroy() {} };
}
recomputeFrameId = window.requestAnimationFrame(() => {
recompute();
animationFrameId = window.requestAnimationFrame(tick);
});
if (typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(() => recompute());
resizeObserver.observe(containerElement);
}
const onLayoutChange = () => recompute();
window.addEventListener("resize", onLayoutChange);
window.addEventListener("scroll", onLayoutChange, true);
removeWindowListeners = () => {
window.removeEventListener("resize", onLayoutChange);
window.removeEventListener("scroll", onLayoutChange, true);
};
return {
update() {},
destroy() {
if (recomputeFrameId !== null)
window.cancelAnimationFrame(recomputeFrameId);
if (animationFrameId !== null)
window.cancelAnimationFrame(animationFrameId);
resizeObserver?.disconnect();
resizeObserver = null;
removeWindowListeners?.();
removeWindowListeners = null;
containerElement = null;
},
};
},
{},
),
style: {
position: "relative",
width: "100%",
height: `${height}px`,
overflow: "hidden",
},
};
}
export { animatedBeam };