Animated Beam
Animated gradient beam that connects two elements with a flowing light path. Install Animated Beam from the VLLNT UI registry with the shadcn CLI.
Animated gradient beam that connects two elements with a flowing light path. Part of the data-display family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
Add Animated Beam to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/animated-beam.jsonSource
"use client";
import * as React from "react";
import { cn } from "../../lib/utils";
/** Props for {@link AnimatedBeam}. */
export type AnimatedBeamProps = React.ComponentPropsWithoutRef<"svg"> & {
/** Element that hosts the beam and supplies the coordinate origin. */
containerRef: React.RefObject<HTMLElement | null>;
/** Curve bow height in pixels. Positive bends up. Defaults to `0`. */
curvature?: number;
/** Seconds for one travel of the gradient. Defaults to `3`. */
duration?: number;
/** Element the beam starts from. */
fromRef: React.RefObject<HTMLElement | null>;
/** Reverse the travel direction of the gradient. */
reverse?: boolean;
/** Element the beam ends at. */
toRef: React.RefObject<HTMLElement | null>;
};
type BeamGeometry = { height: number; path: string; width: number };
type GeometryInput = {
container: HTMLElement | null;
curvature: number;
from: HTMLElement | null;
to: HTMLElement | null;
};
function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = React.useState(false);
React.useEffect(() => {
if (
typeof window === "undefined" ||
typeof window.matchMedia !== "function"
) {
return;
}
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const onChange = (): void => {
setReduced(query.matches);
};
onChange();
query.addEventListener("change", onChange);
return () => {
query.removeEventListener("change", onChange);
};
}, []);
return reduced;
}
function buildGeometry({
container,
curvature,
from,
to,
}: GeometryInput): BeamGeometry {
if (container === null || from === null || to === null) {
return { height: 0, path: "", width: 0 };
}
const base = container.getBoundingClientRect();
const start = from.getBoundingClientRect();
const end = to.getBoundingClientRect();
const startX = start.left - base.left + start.width / 2;
const startY = start.top - base.top + start.height / 2;
const endX = end.left - base.left + end.width / 2;
const endY = end.top - base.top + end.height / 2;
const controlX = (startX + endX) / 2;
const controlY = (startY + endY) / 2 - curvature;
const path = `M ${startX},${startY} Q ${controlX},${controlY} ${endX},${endY}`;
return { height: base.height, path, width: base.width };
}
function useBeamGeometry(
props: Pick<
AnimatedBeamProps,
"containerRef" | "curvature" | "fromRef" | "toRef"
>,
): BeamGeometry {
const { containerRef, curvature = 0, fromRef, toRef } = props;
const [geometry, setGeometry] = React.useState<BeamGeometry>({
height: 0,
path: "",
width: 0,
});
React.useEffect(() => {
const container = containerRef.current;
const update = (): void => {
setGeometry(
buildGeometry({
container,
curvature,
from: fromRef.current,
to: toRef.current,
}),
);
};
update();
if (container === null || typeof ResizeObserver !== "function") {
return;
}
const observer = new ResizeObserver(update);
observer.observe(container);
return () => {
observer.disconnect();
};
}, [containerRef, curvature, fromRef, toRef]);
return geometry;
}
/**
* Animated gradient beam tracing a curved path between two elements.
*
* Respects `prefers-reduced-motion`: the gradient stops travelling.
*
* @example
* ```tsx
* <AnimatedBeam containerRef={box} fromRef={a} toRef={b} />
* ```
*/
export const AnimatedBeam = ({
className,
containerRef,
curvature = 0,
duration = 3,
fromRef,
ref,
reverse = false,
toRef,
...props
}: AnimatedBeamProps & { ref?: React.Ref<SVGSVGElement> }) => {
const gradientId = React.useId();
const reduced = usePrefersReducedMotion();
const { height, path, width } = useBeamGeometry({
containerRef,
curvature,
fromRef,
toRef,
});
return (
<svg
aria-hidden="true"
className={cn("pointer-events-none absolute inset-0", className)}
fill="none"
ref={ref}
{...props}
height={height}
viewBox={`0 0 ${width} ${height}`}
width={width}
>
<path className="stroke-border" d={path} strokeWidth={2} />
<path d={path} stroke={`url(#${gradientId})`} strokeWidth={2} />
<defs>
<linearGradient
gradientUnits="objectBoundingBox"
id={gradientId}
x1="0%"
x2="20%"
>
<stop offset="0%" stopColor="oklch(var(--primary) / 0)" />
<stop offset="50%" stopColor="oklch(var(--primary))" />
<stop offset="100%" stopColor="oklch(var(--primary) / 0)" />
{reduced ? null : (
<BeamTravel duration={duration} reverse={reverse} />
)}
</linearGradient>
</defs>
</svg>
);
};
AnimatedBeam.displayName = "AnimatedBeam";
function BeamTravel({
duration,
reverse,
}: {
duration: number;
reverse: boolean;
}) {
const x1 = reverse ? "100%;-20%" : "-20%;100%";
const x2 = reverse ? "120%;0%" : "0%;120%";
return (
<>
<animate
attributeName="x1"
dur={`${duration}s`}
repeatCount="indefinite"
values={x1}
/>
<animate
attributeName="x2"
dur={`${duration}s`}
repeatCount="indefinite"
values={x2}
/>
</>
);
}
Stories
Explore every variant and state in the interactive Storybook:
Preview
Switch between light and dark to inspect the embedded Storybook preview.