# 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.

- **Category:** Background
- **Docs:** https://uibeats.com/docs/background/animated-beam
- **Registry:** https://uibeats.com/r/animated-beam.json
- **License:** MIT
- **npm dependencies:** `lucide-react`, `motion`

## Installation

```bash
npx shadcn@latest add https://uibeats.com/r/animated-beam.json
```

Or install the dependencies and copy the source below:

```bash
npm install lucide-react motion
```

## When to use

For architecture and integration diagrams, where the point is that data moves between two things. The path is measured live, so it survives reflow.

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `containerRef` | `RefObject<HTMLElement>` | `-` | The positioned element both endpoints live inside (required) |
| `fromRef` | `RefObject<HTMLElement>` | `-` | Element the beam starts at (required) |
| `toRef` | `RefObject<HTMLElement>` | `-` | Element the beam ends at (required) |
| `curvature` | `number` | `0` | Arc height in pixels; positive bows up, negative bows down |
| `reverse` | `boolean` | `false` | Send the pulse from the end to the start |
| `duration` | `number` | `3` | Seconds for one pulse to travel the path |
| `delay` | `number` | `0` | Seconds before the pulse starts |
| `pathColor` | `string` | `'var(--border)'` | Colour of the static path beneath the pulse |
| `gradientStart` | `string` | `'var(--brand)'` | First colour of the travelling gradient |
| `gradientStop` | `string` | `'var(--accent-pink)'` | Second colour of the travelling gradient |
| `className` | `string` | `''` | Additional CSS classes for the SVG |

## Example usage

```tsx
"use client";

import { useRef } from "react";
import { AnimatedBeam } from "@/components/demo/background/animated-beam";
import { Database, Sparkles } from "lucide-react";
import { GitHubIcon } from "@/components/icons";

const AnimatedBeamUsage = () => {
  const container = useRef<HTMLDivElement>(null);
  const left = useRef<HTMLDivElement>(null);
  const centre = useRef<HTMLDivElement>(null);
  const right = useRef<HTMLDivElement>(null);

  return (
    <div
      ref={container}
      className="relative flex w-full max-w-md items-center justify-between px-4"
    >
      <Node ref={left}>
        <GitHubIcon className="size-5 text-muted-foreground" />
      </Node>
      <Node ref={centre} accent>
        <Sparkles className="size-5 text-brand" />
      </Node>
      <Node ref={right}>
        <Database className="size-5 text-muted-foreground" />
      </Node>

      <AnimatedBeam
        containerRef={container}
        fromRef={left}
        toRef={centre}
        curvature={-40}
      />
      <AnimatedBeam
        containerRef={container}
        fromRef={centre}
        toRef={right}
        curvature={-40}
        delay={0.8}
      />
    </div>
  );
};

function Node({
  ref,
  children,
  accent = false,
}: {
  ref: React.Ref<HTMLDivElement>;
  children: React.ReactNode;
  accent?: boolean;
}) {
  return (
    <div
      ref={ref}
      className={`z-10 flex size-12 items-center justify-center rounded-xl border bg-card shadow-subtle ${
        accent ? "ring-2 ring-brand/30" : ""
      }`}
    >
      {children}
    </div>
  );
}

export default AnimatedBeamUsage;
```

## Source

Save as `components/ui/animated-beam.tsx`.

```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;
```

## Credits

Written by [Claude Code](https://claude.com/claude-code).
