Effects & Borders

Border Beam

A beam of light traveling the border, shifting color as it moves. Pure CSS engine — a rotating conic mask over stationary gradients.

Install

npx shadcn@latest add @paragon/border-beam

border-beam.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

export interface BorderBeamProps extends React.ComponentProps<"div"> {
  /** Seconds per revolution. */
  duration?: number;
  /** Border ring thickness in px. */
  borderWidth?: number;
  /** Overall effect opacity, 0–1. */
  strength?: number;
  /** Corner radius of the ring in px. Match the parent's radius. */
  borderRadius?: number;
  /** Beam colors, painted as stationary glows the beam travels across. */
  colors?: string[];
}

/**
 * A beam of light traveling the border of the parent element.
 *
 * Technique: stationary multi-hue radial gradients are parked around the
 * perimeter; only a conic-gradient mask window (a registered @property angle)
 * rotates over them, so the beam shifts color as it travels for the cost of
 * one interpolated value. Confined to the border via the double-mask ring
 * trick. Absolutely positioned — parent needs position: relative.
 */
export function BorderBeam({
  duration = 6,
  borderWidth = 1,
  strength = 1,
  borderRadius = 12,
  colors = ["#f43f5e", "#3b82f6", "#22c55e", "#a855f7", "#f97316"],
  className,
  style,
  ...props
}: BorderBeamProps) {
  const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");

  const positions = [
    "20% 0%",
    "100% 30%",
    "80% 100%",
    "0% 70%",
    "55% 100%",
  ];
  const field = colors
    .map(
      (color, i) =>
        `radial-gradient(ellipse 120px 60px at ${positions[i % positions.length]}, ${color}, transparent)`,
    )
    .join(",");

  return (
    <>
      <style href={`paragon-border-beam-${id}`} precedence="paragon">{`
        @property --beam-angle-${id} {
          syntax: "<angle>";
          initial-value: 0deg;
          inherits: false;
        }
        @keyframes beam-spin-${id} {
          0% { --beam-angle-${id}: 0deg; }
          50% { --beam-angle-${id}: 180deg; }
          100% { --beam-angle-${id}: 360deg; }
        }
        @media (prefers-reduced-motion: reduce) {
          [data-beam="${id}"] { animation: none !important; }
        }
      `}</style>
      <div
        aria-hidden
        data-beam={id}
        className={cn("pointer-events-none absolute inset-0", className)}
        style={
          {
            borderRadius,
            padding: borderWidth,
            background: field,
            opacity: 0.9 * strength,
            mask: `conic-gradient(from var(--beam-angle-${id}), transparent 0%, transparent 55%, rgba(255,255,255,0.4) 70%, white 80%, white 92%, transparent 98%), linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)`,
            WebkitMaskComposite: "source-in, xor",
            maskComposite: "intersect, exclude",
            animation: `beam-spin-${id} ${duration}s linear infinite`,
            ...style,
          } as React.CSSProperties
        }
        {...props}
      />
    </>
  );
}