An SVG beam of light that travels a path between two anchored elements, for integration and data-flow diagrams.
npx shadcn@latest add @paragon/animated-beam"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface AnimatedBeamProps extends React.ComponentProps<"svg"> {
/** The positioned (`position: relative`) container both anchors live inside. */
containerRef: React.RefObject<HTMLElement | null>;
/** Element the beam departs from. */
fromRef: React.RefObject<HTMLElement | null>;
/** Element the beam arrives at. */
toRef: React.RefObject<HTMLElement | null>;
/** Vertical bow of the path in px. Positive bows up, negative down, 0 is straight. */
curvature?: number;
/** Seconds per pulse cycle (travel + rest). */
duration?: number;
/** Animation delay in seconds. Use negative values to phase-offset multiple beams. */
delay?: number;
/** Send the pulse from `toRef` back to `fromRef` instead. */
reverse?: boolean;
/** Color of the static rail the pulse travels along. */
pathColor?: string;
/** Stroke width of rail and pulse, in px. */
pathWidth?: number;
/** Opacity of the static rail, 0–1. */
pathOpacity?: number;
/** Pulse color at the source end of the path. */
gradientFrom?: string;
/** Pulse color at the target end of the path. */
gradientTo?: string;
/** Nudge the start anchor, in px from the source element's center. */
startXOffset?: number;
startYOffset?: number;
/** Nudge the end anchor, in px from the target element's center. */
endXOffset?: number;
endYOffset?: number;
}
interface BeamGeometry {
width: number;
height: number;
d: string;
startX: number;
startY: number;
endX: number;
endY: number;
}
/** Pulse length in normalized path units (path is normalized to 100 via pathLength). */
const PULSE_LENGTH = 32;
/** Full dash cycle — longer than the path so each pass is followed by a beat of rest. */
const DASH_PERIOD = 200;
/**
* An SVG beam of light traveling a path between two anchored elements, for
* integration and data-flow diagrams.
*
* Technique: the path is normalized to 100 units via `pathLength`, a stationary
* source→target gradient is parked along it, and the only animated value is
* `stroke-dashoffset` — a mask window sliding over the gradient, so the pulse
* shifts color as it travels. Geometry is derived from the anchor elements'
* rects and kept in sync with a ResizeObserver. Decorative: pointer-events
* off, aria-hidden, pauses offscreen, and reduced motion shows the full path
* as a static gradient stroke.
*/
export function AnimatedBeam({
containerRef,
fromRef,
toRef,
curvature = 0,
duration = 5,
delay = 0,
reverse = false,
pathColor = "var(--color-border)",
pathWidth = 1.5,
pathOpacity = 1,
gradientFrom = "#3b82f6",
gradientTo = "#8b5cf6",
startXOffset = 0,
startYOffset = 0,
endXOffset = 0,
endYOffset = 0,
className,
ref,
...props
}: AnimatedBeamProps) {
const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
const svgRef = React.useRef<SVGSVGElement | null>(null);
const [geometry, setGeometry] = React.useState<BeamGeometry | null>(null);
const [inView, setInView] = React.useState(true);
const composedRef = (node: SVGSVGElement | null) => {
svgRef.current = node;
if (typeof ref === "function") {
ref(node);
} else if (ref) {
ref.current = node;
}
};
React.useEffect(() => {
const container = containerRef.current;
const from = fromRef.current;
const to = toRef.current;
if (!container || !from || !to) return;
const update = () => {
const c = container.getBoundingClientRect();
if (c.width === 0 || c.height === 0) return;
const a = from.getBoundingClientRect();
const b = to.getBoundingClientRect();
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;
setGeometry({
width: c.width,
height: c.height,
d: `M ${startX},${startY} Q ${controlX},${controlY} ${endX},${endY}`,
startX,
startY,
endX,
endY,
});
};
update();
const observer = new ResizeObserver(update);
observer.observe(container);
observer.observe(from);
observer.observe(to);
return () => observer.disconnect();
}, [
containerRef,
fromRef,
toRef,
curvature,
startXOffset,
startYOffset,
endXOffset,
endYOffset,
]);
React.useEffect(() => {
const node = svgRef.current;
if (!node) return;
const observer = new IntersectionObserver(([entry]) => {
if (entry) setInView(entry.isIntersecting);
});
observer.observe(node);
return () => observer.disconnect();
}, []);
return (
<>
<style href={`paragon-animated-beam-${id}`} precedence="paragon">{`
@keyframes beam-travel-${id} {
from { stroke-dashoffset: ${PULSE_LENGTH}; }
to { stroke-dashoffset: ${PULSE_LENGTH - DASH_PERIOD}; }
}
@media (prefers-reduced-motion: reduce) {
[data-beam-pulse="${id}"] {
animation: none !important;
stroke-dasharray: none !important;
opacity: 0.5;
}
}
`}</style>
<svg
ref={composedRef}
aria-hidden
fill="none"
width={geometry?.width ?? 0}
height={geometry?.height ?? 0}
viewBox={`0 0 ${geometry?.width ?? 0} ${geometry?.height ?? 0}`}
xmlns="http://www.w3.org/2000/svg"
className={cn("pointer-events-none absolute inset-0", className)}
{...props}
>
{geometry && (
<>
<path
d={geometry.d}
stroke={pathColor}
strokeWidth={pathWidth}
strokeOpacity={pathOpacity}
strokeLinecap="round"
/>
<path
data-beam-pulse={id}
d={geometry.d}
pathLength={100}
stroke={`url(#beam-gradient-${id})`}
strokeWidth={pathWidth}
strokeLinecap="round"
style={{
strokeDasharray: `${PULSE_LENGTH} ${DASH_PERIOD - PULSE_LENGTH}`,
strokeDashoffset: PULSE_LENGTH,
animation: `beam-travel-${id} ${duration}s linear ${delay}s infinite ${reverse ? "reverse" : "normal"}`,
animationPlayState: inView ? "running" : "paused",
}}
/>
<defs>
<linearGradient
id={`beam-gradient-${id}`}
gradientUnits="userSpaceOnUse"
x1={geometry.startX}
y1={geometry.startY}
x2={geometry.endX}
y2={geometry.endY}
>
<stop offset="0" stopColor={gradientFrom} stopOpacity="0" />
<stop offset="0.25" stopColor={gradientFrom} />
<stop offset="1" stopColor={gradientTo} />
</linearGradient>
</defs>
</>
)}
</svg>
</>
);
}