Effects & Borders

Animated Beam

An SVG beam of light that travels a path between two anchored elements, for integration and data-flow diagrams.

Install

npx shadcn@latest add @paragon/animated-beam

animated-beam.tsx

"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>
    </>
  );
}