Border Beam
A light segment travels around any card's border.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/border-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/border-beam/border-beam.tsx"use client"; import { useEffect, useRef, type ComponentProps, type ReactNode } from "react"; import { cn } from "@/lib/utils"; export interface BorderBeamProps extends Omit<ComponentProps<"div">, "children"> { /** The card content. */ children: ReactNode; /** Seconds for one lap. @default 6 */ duration?: number; /** Color of the beam's soft tail. @default "var(--primary)" */ colorFrom?: string; /** Color of the beam's bright head. @default "#A855F7" */ colorTo?: string; /** Beam length as a share of the border, 0.05–0.5. @default 0.18 */ size?: number; /** Corner radius in pixels; match the card's. @default 16 */ radius?: number; /** Beam width in pixels. @default 1.5 */ borderWidth?: number; /** Travel counter-clockwise. @default false */ reverse?: boolean; /** Seconds before the first lap (offset several beams). @default 0 */ delay?: number; } export function BorderBeam({ children, duration = 6, colorFrom = "var(--primary)", colorTo = "#A855F7", size = 0.18, radius = 16, borderWidth = 1.5, reverse = false, delay = 0, className, style, ...props }: BorderBeamProps) { const beam = useRef<SVGGElement>(null); useEffect(() => { const group = beam.current; if (!group) return; const query = window.matchMedia("(prefers-reduced-motion: reduce)"); let animations: Animation[] = []; const start = () => { animations.forEach((a) => a.cancel()); animations = []; if (query.matches) return; group.querySelectorAll("rect").forEach((rect) => { const from = Number(rect.dataset.offset ?? 0); const frames = reverse ? [{ strokeDashoffset: from - 1 }, { strokeDashoffset: from }] : [{ strokeDashoffset: from }, { strokeDashoffset: from - 1 }]; animations.push(rect.animate(frames, { duration: duration * 1000, delay: delay * 1000, iterations: Infinity, easing: "linear" })); }); }; start(); query.addEventListener("change", start); return () => { query.removeEventListener("change", start); animations.forEach((a) => a.cancel()); }; }, [duration, reverse, delay]); const head = Math.min(0.08, size / 3); const rect = { x: borderWidth / 2, y: borderWidth / 2, rx: radius, fill: "none", pathLength: 1, style: { width: `calc(100% - ${borderWidth}px)`, height: `calc(100% - ${borderWidth}px)` } }; return ( <div className={cn("relative rounded-2xl border border-border bg-background", className)} style={{ borderRadius: radius, ...style }} {...props}> {children} <svg aria-hidden="true" className="pointer-events-none absolute -inset-px size-[calc(100%+2px)] overflow-visible"> <g ref={beam}> <rect {...rect} stroke={colorFrom} strokeOpacity={0.55} strokeWidth={borderWidth} strokeDasharray={`${size} ${1 - size}`} strokeDashoffset={0} data-offset={0} /> <rect {...rect} stroke={colorTo} strokeWidth={borderWidth + 0.5} strokeLinecap="round" strokeDasharray={`${head} ${1 - head}`} strokeDashoffset={reverse ? 0 : -(size - head)} data-offset={reverse ? 0 : -(size - head)} style={{ ...rect.style, filter: `drop-shadow(0 0 4px ${colorTo})` }} /> </g> </svg> </div> ); }
Usage
import { BorderBeam } from "@/components/ui/border-beam/border-beam";
<BorderBeam className="p-6" radius={16}>
<YourCardContent />
</BorderBeam>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
children* | ReactNode | – | The card content. |
duration | number | 6 | Seconds for one lap. |
colorFrom | string | "var(--primary)" | Color of the beam's soft tail. |
colorTo | string | "#A855F7" | Color of the beam's bright head. |
size | number | 0.18 | Beam length as a share of the border, 0.05–0.5. |
radius | number | 16 | Corner radius in pixels; match the card's. |
borderWidth | number | 1.5 | Beam width in pixels. |
reverse | boolean | false | Travel counter-clockwise. |
delay | number | 0 | Seconds before the first lap (offset several beams). |
Also accepts every native <div> attribute, including ref and className.
- Two dashed strokes (a soft tail and a bright head) on a rounded rect with pathLength 1, animated with the Web Animations API. The beam moves at constant speed around any card size with no per-frame JavaScript.
- Set radius to your card's corner radius. Stack two beams with a delay of half the duration to send them round half a lap apart.
Accessibility
- The beam is a decorative SVG (aria-hidden); the card content is untouched.
- With reduced motion the beam doesn't travel, and the change applies live if the setting flips.