An SVG beam that connects two elements with a curved path and sends a glowing gradient pulse along it. Follows layout changes, pauses off-screen, static under reduced motion.
"use client"
import * as React from "react"
import { Calendar, Database, FileText, GitBranch, Globe, Mail, Monitor, Sparkles } from "lucide-react"
import { cn } from "@/lib/utils"
import { AnimatedBeam } from "@/components/ballmac/animated-beam"
function Node({
className,
children,
label,
ref,
}: {
className?: string
children: React.ReactNode
label: string
ref: React.Ref<HTMLDivElement>
}) {
return (
<div
ref={ref}
title={label}
className={cn(
"relative z-10 flex size-11 items-center justify-center rounded-full border bg-card text-foreground shadow-[0_1px_2px_0_rgb(0_0_0/0.06),0_4px_12px_-4px_rgb(0_0_0/0.12)] [&_svg]:size-[18px]",
className
)}
>
{children}
<span className="sr-only">{label}</span>
</div>
)
}
const tools = [
{ label: "Database", icon: Database },
{ label: "Repository", icon: GitBranch },
{ label: "Email", icon: Mail },
{ label: "Calendar", icon: Calendar },
{ label: "Documents", icon: FileText },
{ label: "Web search", icon: Globe },
]
export default function AnimatedBeamDemo() {
const containerRef = React.useRef<HTMLDivElement>(null)
const agentRef = React.useRef<HTMLDivElement>(null)
const appRef = React.useRef<HTMLDivElement>(null)
const r0 = React.useRef<HTMLDivElement>(null)
const r1 = React.useRef<HTMLDivElement>(null)
const r2 = React.useRef<HTMLDivElement>(null)
const r3 = React.useRef<HTMLDivElement>(null)
const r4 = React.useRef<HTMLDivElement>(null)
const r5 = React.useRef<HTMLDivElement>(null)
const toolRefs = [r0, r1, r2, r3, r4, r5]
const curves = [70, 40, 12, -12, -40, -70]
return (
<div
ref={containerRef}
className="relative flex w-full max-w-xl items-center justify-between gap-4 px-4 py-2 sm:px-8"
>
<div className="flex flex-col gap-2.5">
{tools.map(({ label, icon: Icon }, i) => (
<Node key={label} ref={toolRefs[i]!} label={label}>
<Icon />
</Node>
))}
</div>
<div className="flex flex-col items-center gap-2.5">
<Node
ref={agentRef}
label="Agent"
className="size-16 rounded-2xl border-foreground/10 bg-primary text-primary-foreground shadow-[inset_0_1px_0_0_rgb(255_255_255/0.14),0_8px_24px_-6px_color-mix(in_oklch,var(--chart-1)_45%,transparent)] [&_svg]:size-7"
>
<Sparkles />
</Node>
<span className="font-mono text-[11px] tracking-wide text-muted-foreground">agent</span>
</div>
<div className="flex flex-col items-center gap-2.5">
<Node ref={appRef} label="Your app" className="size-14 rounded-2xl [&_svg]:size-6">
<Monitor />
</Node>
<span className="font-mono text-[11px] tracking-wide text-muted-foreground">your app</span>
</div>
{toolRefs.map((ref, i) => (
<AnimatedBeam
key={i}
containerRef={containerRef}
fromRef={ref}
toRef={agentRef}
curvature={curves[i]}
duration={3.2}
delay={i * 0.35}
/>
))}
<AnimatedBeam
containerRef={containerRef}
fromRef={agentRef}
toRef={appRef}
duration={3.2}
delay={0.9}
gradientStartColor="var(--chart-4)"
gradientStopColor="var(--chart-2)"
/>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/animated-beamInstall the dependencies.
$ pnpm add motion@^12Copy the source into your project.
components/ballmac/animated-beam.tsx// Ballmac UI: Animated Beam. https://ui.ballmac.com/components/animated-beam // Based on Magic UI's Animated Beam (MIT, Copyright (c) Magic UI), rewritten: the pulse is a dash that follows the curve with a head-to-tail gradient, token colors, a glow, off-screen pausing and a static reduced-motion state. "use client" import * as React from "react" import { useReducedMotion } from "motion/react" import { cn } from "@/lib/utils" type AnimatedBeamProps = Omit<React.ComponentProps<"svg">, "children"> & { /** The positioned element both endpoints live in. The beam's SVG covers it. */ containerRef: React.RefObject<HTMLElement | null> /** Element the beam starts from (its center, plus the start offsets). */ fromRef: React.RefObject<HTMLElement | null> /** Element the beam ends at (its center, plus the end offsets). */ toRef: React.RefObject<HTMLElement | null> /** How far the curve bows, in pixels. Positive bends up, negative bends down, 0 is straight. */ curvature?: number /** Send the pulse from `toRef` to `fromRef`. */ reverse?: boolean /** Seconds for the pulse to travel the path once. */ duration?: number /** Seconds before the first pulse. Stagger beams with different delays. */ delay?: number /** Seconds to wait between pulses. */ repeatDelay?: number /** Length of the pulse as a fraction of the path (0–1). */ pulseLength?: number /** Horizontal offset of the start point in pixels. */ startXOffset?: number /** Vertical offset of the start point in pixels. */ startYOffset?: number /** Horizontal offset of the end point in pixels. */ endXOffset?: number /** Vertical offset of the end point in pixels. */ endYOffset?: number /** Color of the resting path. Any CSS color or var(). */ pathColor?: string /** Stroke width of the path and pulse in pixels. */ pathWidth?: number /** Opacity of the resting path (0–1). */ pathOpacity?: number /** Color at the tail of the pulse. */ gradientStartColor?: string /** Color at the head of the pulse. */ gradientStopColor?: string } type Geometry = { width: number; height: number; d: string } // Gentle in-out so the pulse accelerates away and settles in, like a signal arriving. const easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2) function AnimatedBeam({ containerRef, fromRef, toRef, curvature = 0, reverse = false, duration = 3.5, delay = 0, repeatDelay = 0.8, pulseLength = 0.35, startXOffset = 0, startYOffset = 0, endXOffset = 0, endYOffset = 0, pathColor = "var(--foreground)", pathWidth = 2, pathOpacity = 0.1, gradientStartColor = "var(--chart-1)", gradientStopColor = "var(--chart-4)", className, style, ...props }: AnimatedBeamProps) { const uid = React.useId().replace(/[^a-zA-Z0-9_-]/g, "") const gradientId = `bm-beam-${uid}` const glowId = `bm-beam-glow-${uid}` const reduceMotion = useReducedMotion() const svgRef = React.useRef<SVGSVGElement>(null) const pulseRef = React.useRef<SVGPathElement>(null) const glowRef = React.useRef<SVGPathElement>(null) const gradientRef = React.useRef<SVGLinearGradientElement>(null) const [geometry, setGeometry] = React.useState<Geometry>({ width: 0, height: 0, d: "" }) // Measure the endpoints relative to the container and rebuild the curve on any resize. React.useEffect(() => { const container = containerRef.current if (!container) return const update = () => { const from = fromRef.current const to = toRef.current if (!from || !to) return const box = container.getBoundingClientRect() const a = from.getBoundingClientRect() const b = to.getBoundingClientRect() const sx = a.left - box.left + a.width / 2 + startXOffset const sy = a.top - box.top + a.height / 2 + startYOffset const ex = b.left - box.left + b.width / 2 + endXOffset const ey = b.top - box.top + b.height / 2 + endYOffset const d = `M ${sx},${sy} Q ${(sx + ex) / 2},${(sy + ey) / 2 - curvature} ${ex},${ey}` setGeometry((prev) => prev.d === d && prev.width === box.width && prev.height === box.height ? prev : { width: box.width, height: box.height, d } ) } update() const observer = new ResizeObserver(update) observer.observe(container) if (fromRef.current) observer.observe(fromRef.current) if (toRef.current) observer.observe(toRef.current) return () => observer.disconnect() }, [containerRef, fromRef, toRef, curvature, startXOffset, startYOffset, endXOffset, endYOffset]) // Drive the pulse from one rAF loop: a dash slides along the path and the gradient follows it. React.useEffect(() => { const svg = svgRef.current const pulse = pulseRef.current const glow = glowRef.current const gradient = gradientRef.current if (!svg || !pulse || !glow || !gradient || !geometry.d || reduceMotion) return if (typeof pulse.getTotalLength !== "function") return const total = pulse.getTotalLength() if (!total) return const length = Math.min(Math.max(pulseLength, 0.05), 1) const cycle = duration + repeatDelay const at = (fraction: number) => { const along = Math.min(Math.max(fraction, 0), 1) * total return pulse.getPointAtLength(reverse ? total - along : along) } let frame = 0 let visible = true let start: number | null = null const paint = (now: number) => { start ??= now const elapsed = (now - start) / 1000 - delay const local = elapsed < 0 ? -1 : (elapsed % cycle) / duration if (local < 0 || local > 1) { pulse.style.opacity = glow.style.opacity = "0" } else { const head = easeInOut(local) * (1 + length) const tail = head - length // The dash covers [tail, head] along the path, measured from the travel start. const offset = reverse ? String(-(1 - head)) : String(-tail) pulse.style.strokeDashoffset = glow.style.strokeDashoffset = offset pulse.style.opacity = "1" glow.style.opacity = "0.55" const t = at(tail) const h = at(head) gradient.setAttribute("x1", String(t.x)) gradient.setAttribute("y1", String(t.y)) gradient.setAttribute("x2", String(h.x)) gradient.setAttribute("y2", String(h.y)) } frame = requestAnimationFrame(paint) } const run = () => { cancelAnimationFrame(frame) if (visible && !document.hidden) frame = requestAnimationFrame(paint) } const io = new IntersectionObserver(([entry]) => { visible = entry?.isIntersecting ?? true run() }) io.observe(svg) document.addEventListener("visibilitychange", run) run() return () => { cancelAnimationFrame(frame) io.disconnect() document.removeEventListener("visibilitychange", run) } }, [geometry.d, reduceMotion, duration, delay, repeatDelay, pulseLength, reverse]) const staticBeam = reduceMotion === true const dash = staticBeam ? undefined : `${Math.min(Math.max(pulseLength, 0.05), 1)} 3` return ( <svg ref={svgRef} aria-hidden="true" data-slot="animated-beam" fill="none" width={geometry.width} height={geometry.height} viewBox={`0 0 ${geometry.width} ${geometry.height}`} className={cn("pointer-events-none absolute top-0 start-0 overflow-visible", className)} style={style} {...props} > <defs> <linearGradient ref={gradientRef} id={gradientId} gradientUnits="userSpaceOnUse" x1="0" y1="0" x2={geometry.width} y2="0" > <stop offset="0%" style={{ stopColor: gradientStartColor, stopOpacity: 0 }} /> <stop offset="35%" style={{ stopColor: gradientStartColor }} /> <stop offset="100%" style={{ stopColor: gradientStopColor }} /> </linearGradient> <filter id={glowId} x="-50%" y="-50%" width="200%" height="200%"> <feGaussianBlur stdDeviation={pathWidth * 1.5} /> </filter> </defs> <path data-slot="animated-beam-path" d={geometry.d} strokeWidth={pathWidth} strokeLinecap="round" style={{ stroke: pathColor, strokeOpacity: pathOpacity }} /> <path ref={glowRef} d={geometry.d} pathLength={1} stroke={`url(#${gradientId})`} strokeWidth={pathWidth * 3} strokeLinecap="round" strokeDasharray={dash} filter={`url(#${glowId})`} style={{ opacity: 0 }} /> <path ref={pulseRef} data-slot="animated-beam-pulse" d={geometry.d} pathLength={1} stroke={`url(#${gradientId})`} strokeWidth={pathWidth} strokeLinecap="round" strokeDasharray={dash} style={{ opacity: staticBeam ? 0.45 : 0 }} /> </svg> ) } export { AnimatedBeam, type AnimatedBeamProps }Update the import paths to match your project setup.
Usage
import { AnimatedBeam } from "@/components/ballmac/animated-beam"The full example is in the Code tab above.
Examples
Two-way sync
"use client"
import * as React from "react"
import { Cloud, Laptop } from "lucide-react"
import { AnimatedBeam } from "@/components/ballmac/animated-beam"
export default function AnimatedBeamBidirectional() {
const containerRef = React.useRef<HTMLDivElement>(null)
const laptopRef = React.useRef<HTMLDivElement>(null)
const cloudRef = React.useRef<HTMLDivElement>(null)
return (
<div ref={containerRef} className="relative flex w-full max-w-md items-center justify-between px-4 py-10">
<div className="relative z-10 flex flex-col items-center gap-2">
<div
ref={laptopRef}
className="flex size-14 items-center justify-center rounded-2xl border bg-card shadow-sm"
>
<Laptop className="size-6" aria-hidden="true" />
</div>
<span className="text-xs text-muted-foreground">This Mac</span>
</div>
<div className="relative z-10 flex flex-col items-center gap-2">
<div
ref={cloudRef}
className="flex size-14 items-center justify-center rounded-2xl border bg-card shadow-sm"
>
<Cloud className="size-6" aria-hidden="true" />
</div>
<span className="text-xs text-muted-foreground">Cloud</span>
</div>
<AnimatedBeam
containerRef={containerRef}
fromRef={laptopRef}
toRef={cloudRef}
curvature={36}
/>
<AnimatedBeam
containerRef={containerRef}
fromRef={laptopRef}
toRef={cloudRef}
curvature={-36}
reverse
delay={1.6}
gradientStartColor="var(--chart-2)"
gradientStopColor="var(--chart-1)"
/>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
containerRef*The positioned element both endpoints live in. The beam's SVG covers it. | React.RefObject<HTMLElement | null> | — |
fromRef*Element the beam starts from (its center, plus the start offsets). | React.RefObject<HTMLElement | null> | — |
toRef*Element the beam ends at (its center, plus the end offsets). | React.RefObject<HTMLElement | null> | — |
curvatureHow far the curve bows, in pixels. Positive bends up, negative bends down, 0 is straight. | number | 0 |
reverseSend the pulse from `toRef` to `fromRef`. | boolean | false |
durationSeconds for the pulse to travel the path once. | number | 3.5 |
delaySeconds before the first pulse. Stagger beams with different delays. | number | 0 |
repeatDelaySeconds to wait between pulses. | number | 0.8 |
pulseLengthLength of the pulse as a fraction of the path (0–1). | number | 0.35 |
startXOffsetHorizontal offset of the start point in pixels. | number | 0 |
startYOffsetVertical offset of the start point in pixels. | number | 0 |
endXOffsetHorizontal offset of the end point in pixels. | number | 0 |
endYOffsetVertical offset of the end point in pixels. | number | 0 |
pathColorColor of the resting path. Any CSS color or var(). | string | "var(--foreground)" |
pathWidthStroke width of the path and pulse in pixels. | number | 2 |
pathOpacityOpacity of the resting path (0–1). | number | 0.1 |
gradientStartColorColor at the tail of the pulse. | string | "var(--chart-1)" |
gradientStopColorColor at the head of the pulse. | string | "var(--chart-4)" |
Also accepts the standard attributes of its root element.
Use with AI
Give a relative container a ref, give each endpoint element a ref, and render <AnimatedBeam containerRef fromRef toRef /> inside the container once per connection. Put endpoint nodes above the beams with relative z-10. The SVG is aria-hidden and pointer-events-none. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Animated Beam (@ballmac/animated-beam) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Integration diagrams: tools or data sources flowing into an agent or product
- Showing a request path between services on a landing page
- Explaining a sync or pipeline visually in a feature section
Not for
- Real, data-driven graphs with many nodes (use a graph library)
- Conveying state on its own; label the nodes, the beam is decorative
Registry JSON: https://ui.ballmac.com/r/animated-beam.json
Credits
Based on Magic UI Animated Beam, adapted by Ballmac. Free to use in personal and commercial projects.
- npm
- motion@^12
- Registry
- shadcn/utils
Pairs well with
Orbiting Circles
Items that orbit a center on circular tracks while staying upright. Evenly spaced, any radius, speed and direction; stack several for rings. Paused off-screen, static under reduced motion.
Dot Pattern
A lightweight SVG dot grid background with an optional radial fade and a few softly pulsing glow dots. One pattern element, token colors, hydration-safe, still under reduced motion.
Border Beam
A short beam of light that travels around the inside edge of its parent's border at a constant speed. Decorative, token-colored, hidden under reduced motion.
Animated Grid
A decorative hairline grid background where a few random cells softly light up and fade, like instrument lights. SVG, hydration-safe, static under reduced motion.