MynduiMyndui
0

Anatomy of Animated Beam

How a travelling gradient is drawn along a live-measured quadratic path between two DOM nodes, and why the path itself never has to move.

01/04From, to, and one quadratic curve
From
fromRef center, +startXOffset/startYOffset
To
toRef center, +endXOffset/endYOffset
Path
M start Q (mid, mid − curvature) end
tsx
const startX = a.left - c.left + a.width / 2 + startXOffset;const startY = a.top - c.top + a.height / 2 + startYOffset;const endX = b.left - c.left + b.width / 2 + endXOffset;const endY = b.top - c.top + b.height / 2 + endYOffset;const controlX = (startX + endX) / 2;const controlY = (startY + endY) / 2 - curvature;setPath(`M ${startX},${startY} Q ${controlX},${controlY} ${endX},${endY}`);
01

From, to, and one quadratic curve

AnimatedBeam doesn't animate a line between two elements — it draws one static path and slides a gradient across it. Two completely separate problems, solved independently: geometry (where the path goes) and motion (what appears to travel along it).

Everything starts from two refs. AnimatedBeam reads both elements' getBoundingClientRect() relative to the container, takes their centers (plus optional pixel offsets), and connects them with a single quadratic Bézier: M start Q control end. The control point sits at the horizontal midpoint, pushed up or down by curvature.

One <path> renders that string twice: once as a faint resting line (pathColor, pathOpacity), once with a gradient stroke on top. The second copy is the one that actually moves.

02/04Live geometry, not a one-time measurement

update() re-runs on every ResizeObserver + window resize tick

From node
container/window resize moves it
Path
d re-derived from live rects, every measure
ResizeObserver
+ window resize listener trigger update()
tsx
const update = () => {const c = container.getBoundingClientRect();const a = from.getBoundingClientRect();const b = to.getBoundingClientRect();setBox({ width: c.width, height: c.height });setPath(/* same formula, fresh rects */);};update();const ro = new ResizeObserver(update);ro.observe(containerRef.current);window.addEventListener("resize", update);
02

Live geometry, not a one-time measurement

Refs don't dispatch events when their layout changes, so AnimatedBeam watches instead. A ResizeObserver on the container plus a window resize listener both call the same update() — recomputing every rect and rewriting d from scratch. Nothing is interpolated between measurements; each tick is a fresh, correct snapshot.

Sizing the <svg> from the same container rect (box.width/box.height) keeps the overlay pixel-aligned with whatever just resized it — there's no separate "layout" pass to fall out of sync with.

03/04A gradient that travels
Gradient
x1/x2 tween 10%→110%, linear, 3s, infinite
Path
static, pathOpacity 0.2 — never redrawn for this
Reverse
flips the array: 90%→−10% instead
tsx
<motion.linearGradientgradientUnits="userSpaceOnUse"animate={{ x1: ["10%", "110%"], x2: ["0%", "100%"] }}transition={{ duration, delay, repeat: Infinity, ease: "linear" }}><stop stopColor={gradientStartColor} stopOpacity="0" /><stop stopColor={gradientStartColor} /><stop offset="32.5%" stopColor={gradientStopColor} /><stop offset="100%" stopColor={gradientStopColor} stopOpacity="0" /></motion.linearGradient>
03

A gradient that travels

The animated part is a motion.linearGradient living inside the path's <defs>. Its x1/x2 tween from one edge to past the other — ["10%", "110%"] — on an infinite, linear-eased loop. The path underneath never changes; only where the gradient's stops fall along it does.

reverse doesn't reverse an animation direction flag — it swaps the tween's own start/end percentages (["90%", "-10%"]), so the gradient still animates forward through the same linear timeline, just mapped onto the opposite travel.

The only thing animating every frame is two SVG attributes on a <stop>'s parent — x1/x2 on a linearGradient. That's a paint-level change scoped to the gradient fill, not a layout or geometry recalculation of the <path> itself, and definitely not a JS-driven requestAnimationFrame loop. The expensive part — remeasuring rects and rebuilding d — only runs on an actual resize, not every animation frame.

04/04Result
04

The result

One measured path, one travelling gradient, and a ResizeObserver making sure the first never drifts out from under the second.

Reduced motion

useReducedMotion() swaps the animated gradient tween for a static one — { x1: "0%", x2: "100%" } with duration: 0 — so the beam still reads as a gradient-colored connection, it just never travels.

tsx
 
const reduceMotion = useReducedMotion();

animate={
  reduceMotion
    ? { x1: "0%", x2: "100%" }
    : { x1: gradient.x1, x2: gradient.x2 }
}
transition={reduceMotion ? { duration: 0 } : { duration, delay, repeat: Infinity, ease: "linear" }}

Motion Score

Animated BeamCCPaint-triggering
Cx1SVG endpoint geometry for the length-driven beam
Cx2SVG endpoint geometry for the length-driven beam
Cy1SVG endpoint geometry for the length-driven beam
Cy2SVG endpoint geometry for the length-driven beam
StranslatePosition / lift via translate
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →