shadimage
UI Beats
uianimated-beamsource: ok · refreshed

Animated Beam

The AnimatedBeam component draws a curved line between any two elements and sends a gradient pulse along it, remeasuring whenever the layout moves.

npx shadcn add "https://uibeats.com/r/animated-beam.json"
free

Visual style

gradient

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationmotion

Files1 · source cached 10/2/2026, 1:04:28 AM

components/demo/background/animated-beam.tsx
"use client";

import { motion, useReducedMotion } from "motion/react";
import { useCallback, useEffect, useId, useState, type RefObject } from "react";

interface AnimatedBeamProps {
  /** The positioned element both refs live inside. */
  containerRef: RefObject<HTMLElement | null>;
  fromRef: RefObject<HTMLElement | null>;
  toRef: RefObject<HTMLElement | null>;
  /** Arc height in pixels. Positive bows up, negative bows down, 0 is straight. */
  curvature?: number;
  /** Send the pulse from `toRef` to `fromRef` instead. */
  reverse?: boolean;
  duration?: number;
  delay?: number;
  pathColor?: string;
  gradientStart?: string;
  gradientStop?: string;
  className?: string;
}

/**
 * Draws a curved beam between two elements and sends a gradient pulse along
 * it.
 *
 * The path is measured from the live geometry of the two elements rather than
 * hardcoded, so it survives responsive reflow, font loading and content
 * changes: a `ResizeObserver` on the container recomputes it whenever
 * anything moves.
 */
export function AnimatedBeam({
  containerRef,
  fromRef,
  toRef,
  curvature = 0,
  reverse = false,
  duration = 3,
  delay = 0,
  pathColor = "var(--border)",
  gradientStart = "var(--brand)",
  gradientStop = "var(--accent-pink)",
  className = "",
}: AnimatedBeamProps) {
  // `useId` keeps the gradient id unique; two beams on one page sharing an id
  // would both resolve to whichever was defined last.
  const gradientId = useId();
  const [path, setPath] = useState("");
  const [size, setSize] = useState({ width: 0, height: 0 });
  const prefersReducedMotion = useReducedMotion();

  const measure = useCallback(() => {
    const container = containerRef.current;
    const from = fromRef.current;
    const to = toRef.current;
    if (!container || !from || !to) return;

    const containerRect = container.getBoundingClientRect();
    const fromRect = from.getBoundingClientRect();
    const toRect = to.getBoundingClientRect();

    setSize({ width: containerRect.width, height: containerRect.height });

    // Centres, expressed relative to the container's own coordinate space.
    const startX = fromRect.left - containerRect.left + fromRect.width / 2;
    const startY = fromRect.top - containerRect.top + fromRect.height / 2;
    const endX = toRect.left - containerRect.left + toRect.width / 2;
    const endY = toRect.top - containerRect.top + toRect.height / 2;

    const controlX = (startX + endX) / 2;
    const controlY = (startY + endY) / 2 - curvature;

    setPath(`M ${startX},${startY} Q ${controlX},${controlY} ${endX},${endY}`);
  }, [containerRef, fromRef, toRef, curvature]);

  useEffect(() => {
    measure();

    const container = containerRef.current;
    if (!container) return;

    const observer = new ResizeObserver(measure);
    observer.observe(container);
    // Watch the endpoints too: the container can hold its size while the
    // elements inside it move.
    if (fromRef.current) observer.observe(fromRef.current);
    if (toRef.current) observer.observe(toRef.current);

    return () => observer.disconnect();
  }, [measure, containerRef, fromRef, toRef]);

  if (!path) return null;

  return (
    <svg
      fill="none"
      width={size.width}
      height={size.height}
      viewBox={`0 0 ${size.width} ${size.height}`}
      aria-hidden="true"
      /*
       * `overflow-visible` because the arc is not obliged to stay inside the
       * box the endpoints describe. The SVG is sized to the container, and an
       * SVG clips to its viewport by default, so a curvature taller than the
       * gap between the two elements had its whole middle cut away: at
       * `curvature={90}` across a row of 44px nodes, everything but a stub at
       * each end. The container decides where the beam is allowed to draw;
       * the beam should not clip itself.
       */
      className={`pointer-events-none absolute top-0 left-0 overflow-visible ${className}`}
    >
      <path
        d={path}
        stroke={pathColor}
        strokeWidth={2}
        strokeLinecap="round"
        strokeOpacity={0.4}
      />
      <path
        d={path}
        stroke={`url(#${gradientId})`}
        strokeWidth={2}
        strokeLinecap="round"
      />
      <defs>
        <motion.linearGradient
          id={gradientId}
          gradientUnits="userSpaceOnUse"
          // A narrow gradient window slid across the path is what reads as a
          // pulse travelling along the wire.
          initial={{ x1: "0%", x2: "0%", y1: "0%", y2: "0%" }}
          animate={
            prefersReducedMotion
              ? { x1: "0%", x2: "100%", y1: "0%", y2: "0%" }
              : {
                  x1: reverse ? ["100%", "-10%"] : ["-10%", "100%"],
                  x2: reverse ? ["110%", "0%"] : ["0%", "110%"],
                  y1: ["0%", "0%"],
                  y2: ["0%", "0%"],
                }
          }
          transition={{
            duration,
            delay,
            repeat: prefersReducedMotion ? 0 : Infinity,
            repeatDelay: 0.4,
            ease: "easeInOut",
          }}
        >
          <stop stopColor={gradientStart} stopOpacity="0" />
          <stop stopColor={gradientStart} />
          <stop offset="32.5%" stopColor={gradientStop} />
          <stop offset="100%" stopColor={gradientStop} stopOpacity="0" />
        </motion.linearGradient>
      </defs>
    </svg>
  );
}

export default AnimatedBeam;

More from UI Beats

Browse all components in this library →