Border Beam
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, with a blurred bloom halo riding the beam head.

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;
  /** Soft halo riding the beam head, 0–1. 0 removes the bloom layer. */
  bloom?: 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[];
}

const clamp01 = (value: number) => Math.min(Math.max(value, 0), 1);

/**
 * 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,
 * animated once on the wrapper and inherited by every layer) rotates over
 * them, so the beam shifts color as it travels for the cost of one
 * interpolated value. Two layers ride that angle: a crisp stroke confined to
 * the border via the double-mask ring trick, and a bloom — the same masked
 * ring under a parent blur, isolated on its own element so the halo bleeds
 * softly past the border without ever touching the stroke. The spin pauses
 * offscreen via IntersectionObserver; reduced motion parks the beam as a
 * static gradient arc. Absolutely positioned — parent needs
 * position: relative.
 */
export function BorderBeam({
  duration = 6,
  borderWidth = 1,
  strength = 1,
  bloom = 0.5,
  borderRadius = 12,
  colors = ["#f43f5e", "#3b82f6", "#22c55e", "#a855f7", "#f97316"],
  className,
  style,
  ref: forwardedRef,
  ...props
}: BorderBeamProps) {
  const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
  const localRef = React.useRef<HTMLDivElement | null>(null);
  const [inView, setInView] = React.useState(true);

  React.useEffect(() => {
    const node = localRef.current;
    if (!node) return;
    const observer = new IntersectionObserver(([entry]) => {
      setInView(entry?.isIntersecting ?? true);
    });
    observer.observe(node);
    return () => observer.disconnect();
  }, []);

  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(",");

  /** One ring layer: the color field, masked to the rotating beam window
   *  (multi-stop tail → head) intersected with the border ring. */
  const ringLayer = (padding: number): React.CSSProperties => ({
    position: "absolute",
    inset: 0,
    borderRadius,
    padding,
    background: field,
    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",
  });

  const level = clamp01(strength);
  const bloomLevel = clamp01(bloom);

  return (
    <>
      <style href={`paragon-border-beam-${id}`} precedence="paragon">{`
        @property --beam-angle-${id} {
          syntax: "<angle>";
          initial-value: 0deg;
          inherits: true;
        }
        @keyframes beam-spin-${id} {
          0% { --beam-angle-${id}: 0deg; }
          25% { --beam-angle-${id}: 90deg; }
          50% { --beam-angle-${id}: 180deg; }
          75% { --beam-angle-${id}: 270deg; }
          100% { --beam-angle-${id}: 360deg; }
        }
        @media (prefers-reduced-motion: reduce) {
          [data-beam="${id}"] { animation: none !important; }
        }
      `}</style>
      <div
        aria-hidden
        data-beam={id}
        ref={(node) => {
          localRef.current = node;
          if (typeof forwardedRef === "function") forwardedRef(node);
          else if (forwardedRef) forwardedRef.current = node;
        }}
        className={cn("pointer-events-none absolute inset-0", className)}
        style={{
          borderRadius,
          opacity: 0.9 * level,
          animation: `beam-spin-${id} ${duration}s linear infinite`,
          animationPlayState: inView ? "running" : "paused",
          ...style,
        }}
        {...props}
      >
        {bloomLevel > 0 && (
          // Blur isolated on its own element: the masked ring below is
          // re-rasterized through this parent filter, so the halo spreads
          // both ways across the border instead of being clipped by the mask.
          <div
            style={{
              position: "absolute",
              inset: 0,
              filter: "blur(6px)",
              opacity: 0.65 * bloomLevel,
            }}
          >
            <div style={ringLayer(borderWidth + 1)} />
          </div>
        )}
        <div style={ringLayer(borderWidth)} />
      </div>
    </>
  );
}