Skip to content

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

Manual

  1. Install the dependencies

    npm install clsx tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts. See Installation if you have not set them up yet.

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

PropTypeDefaultDescription
children*ReactNode–The card content.
durationnumber6Seconds for one lap.
colorFromstring"var(--primary)"Color of the beam's soft tail.
colorTostring"#A855F7"Color of the beam's bright head.
sizenumber0.18Beam length as a share of the border, 0.05–0.5.
radiusnumber16Corner radius in pixels; match the card's.
borderWidthnumber1.5Beam width in pixels.
reversebooleanfalseTravel counter-clockwise.
delaynumber0Seconds 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.