Animated Beam
A gradient pulse travels along a curve between two elements.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/animated-beam.jsonManual
Install the dependencies
npm install clsx tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.ts. See Installation if you have not set them up yet.Copy the source
components/ui/animated-beam/animated-beam.tsx"use client"; import { useEffect, useId, useRef, useState, type RefObject } from "react"; import { cn } from "@/lib/utils"; export interface AnimatedBeamProps { /** Positioned element that contains both endpoints; the beam is drawn over it. */ containerRef: RefObject<HTMLElement | null>; /** Element the beam starts from. */ fromRef: RefObject<HTMLElement | null>; /** Element the beam ends at. */ toRef: RefObject<HTMLElement | null>; /** Bend of the curve in pixels; positive bends up, negative down. @default 0 */ curvature?: number; /** Send the pulse from `to` back to `from`. @default false */ reverse?: boolean; /** Seconds for one pulse. @default 3 */ duration?: number; /** Seconds before the first pulse. @default 0 */ delay?: number; /** Color of the resting path. @default "var(--border)" */ pathColor?: string; /** Path width in pixels. @default 2 */ pathWidth?: number; /** Pulse color at the start of the beam. @default "var(--primary)" */ gradientStartColor?: string; /** Pulse color at the end of the beam. @default "#A855F7" */ gradientStopColor?: string; /** Pulse length as a share of the path, 0.05–0.5. @default 0.2 */ pulseLength?: number; /** Horizontal offset from the start element's center, in pixels. @default 0 */ startXOffset?: number; /** Vertical offset from the start element's center, in pixels. @default 0 */ startYOffset?: number; /** Horizontal offset from the end element's center, in pixels. @default 0 */ endXOffset?: number; /** Vertical offset from the end element's center, in pixels. @default 0 */ endYOffset?: number; /** Extra classes for the SVG. */ className?: string; } interface Geometry { width: number; height: number; d: string; x1: number; y1: number; x2: number; y2: number; } export function AnimatedBeam({ containerRef, fromRef, toRef, curvature = 0, reverse = false, duration = 3, delay = 0, pathColor = "var(--border)", pathWidth = 2, gradientStartColor = "var(--primary)", gradientStopColor = "#A855F7", pulseLength = 0.2, startXOffset = 0, startYOffset = 0, endXOffset = 0, endYOffset = 0, className, }: AnimatedBeamProps) { const id = useId(); const pulse = useRef<SVGPathElement>(null); const [geometry, setGeometry] = useState<Geometry | null>(null); // Measure endpoints relative to the container; re-measure whenever any of them resizes. useEffect(() => { const container = containerRef.current; const from = fromRef.current; const to = toRef.current; if (!container || !from || !to) return; const measure = () => { const c = container.getBoundingClientRect(); const a = from.getBoundingClientRect(); const b = to.getBoundingClientRect(); const x1 = a.left - c.left + a.width / 2 + startXOffset; const y1 = a.top - c.top + a.height / 2 + startYOffset; const x2 = b.left - c.left + b.width / 2 + endXOffset; const y2 = b.top - c.top + b.height / 2 + endYOffset; // Control point: the midpoint pushed along the perpendicular by `curvature`. const dx = x2 - x1; const dy = y2 - y1; const length = Math.hypot(dx, dy) || 1; const cx = (x1 + x2) / 2 + (dy / length) * curvature; const cy = (y1 + y2) / 2 - (dx / length) * curvature; setGeometry({ width: c.width, height: c.height, d: `M ${x1},${y1} Q ${cx},${cy} ${x2},${y2}`, x1, y1, x2, y2 }); }; const first = requestAnimationFrame(measure); const observer = new ResizeObserver(measure); [container, from, to].forEach((el) => observer.observe(el)); window.addEventListener("resize", measure); return () => { cancelAnimationFrame(first); observer.disconnect(); window.removeEventListener("resize", measure); }; }, [containerRef, fromRef, toRef, curvature, startXOffset, startYOffset, endXOffset, endYOffset]); // The pulse is a dash travelling along the path (Web Animations API, no per-frame JS). useEffect(() => { const path = pulse.current; if (!path || !geometry) return; const query = window.matchMedia("(prefers-reduced-motion: reduce)"); let animation: Animation | null = null; const start = () => { animation?.cancel(); animation = null; if (query.matches) { path.style.strokeDasharray = "none"; path.style.opacity = "0.45"; return; } path.style.strokeDasharray = `${pulseLength} ${1 - pulseLength}`; path.style.opacity = "1"; const frames = reverse ? [{ strokeDashoffset: -1 }, { strokeDashoffset: pulseLength }] : [{ strokeDashoffset: pulseLength }, { strokeDashoffset: -1 }]; animation = path.animate(frames, { duration: duration * 1000, delay: delay * 1000, iterations: Infinity, easing: "cubic-bezier(0.45, 0, 0.55, 1)", fill: "backwards" }); }; start(); query.addEventListener("change", start); return () => { query.removeEventListener("change", start); animation?.cancel(); }; }, [geometry, duration, delay, reverse, pulseLength]); if (!geometry) return null; return ( <svg aria-hidden="true" width={geometry.width} height={geometry.height} viewBox={`0 0 ${geometry.width} ${geometry.height}`} className={cn("pointer-events-none absolute top-0 left-0 overflow-visible", className)} > <defs> <linearGradient id={`${id}-beam`} gradientUnits="userSpaceOnUse" x1={geometry.x1} y1={geometry.y1} x2={geometry.x2} y2={geometry.y2}> <stop offset="0" style={{ stopColor: gradientStartColor }} /> <stop offset="1" style={{ stopColor: gradientStopColor }} /> </linearGradient> </defs> <path d={geometry.d} fill="none" stroke={pathColor} strokeWidth={pathWidth} strokeLinecap="round" /> <path ref={pulse} d={geometry.d} fill="none" stroke={`url(#${id}-beam)`} strokeWidth={pathWidth} strokeLinecap="round" pathLength={1} strokeDasharray={`${pulseLength} ${1 - pulseLength}`} strokeDashoffset={pulseLength} /> </svg> ); }
Usage
import { AnimatedBeam } from "@/components/ui/animated-beam/animated-beam";
const container = useRef<HTMLDivElement>(null);
const from = useRef<HTMLDivElement>(null);
const to = useRef<HTMLDivElement>(null);
<div ref={container} className="relative flex justify-between">
<div ref={from}>…</div>
<div ref={to}>…</div>
<AnimatedBeam containerRef={container} fromRef={from} toRef={to} curvature={30} />
</div>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
containerRef* | RefObject<HTMLElement | null> | – | Positioned element that contains both endpoints; the beam is drawn over it. |
fromRef* | RefObject<HTMLElement | null> | – | Element the beam starts from. |
toRef* | RefObject<HTMLElement | null> | – | Element the beam ends at. |
curvature | number | 0 | Bend of the curve in pixels; positive bends up, negative down. |
reverse | boolean | false | Send the pulse from `to` back to `from`. |
duration | number | 3 | Seconds for one pulse. |
delay | number | 0 | Seconds before the first pulse. |
pathColor | string | "var(--border)" | Color of the resting path. |
pathWidth | number | 2 | Path width in pixels. |
gradientStartColor | string | "var(--primary)" | Pulse color at the start of the beam. |
gradientStopColor | string | "#A855F7" | Pulse color at the end of the beam. |
pulseLength | number | 0.2 | Pulse length as a share of the path, 0.05–0.5. |
startXOffset | number | 0 | Horizontal offset from the start element's center, in pixels. |
startYOffset | number | 0 | Vertical offset from the start element's center, in pixels. |
endXOffset | number | 0 | Horizontal offset from the end element's center, in pixels. |
endYOffset | number | 0 | Vertical offset from the end element's center, in pixels. |
className | string | – | Extra classes for the SVG. |
- Endpoints are measured relative to the container and re-measured with a ResizeObserver, so beams follow layout changes. The container must be positioned (relative).
- The pulse is a dash on a pathLength-normalized path, animated with the Web Animations API, and the gradient runs from start to end in user space, so the pulse changes color as it travels.
Accessibility
- The beam is decorative (aria-hidden SVG). Convey what the connection means in text nearby.
- With reduced motion the pulse doesn't travel: the beam shows as a soft, still gradient line.