Conic Border
Effects & Borders

Conic Border

A conic-gradient ring that rotates continuously around the border, wheeling its hues around the perimeter with an isolated bloom.

Install

npx shadcn@latest add @paragon/conic-border

conic-border.tsx

"use client";

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

export interface ConicBorderProps extends React.ComponentProps<"div"> {
  /** Seconds per full revolution. */
  duration?: number;
  /** Border ring thickness in px. */
  borderWidth?: number;
  /** Corner radius of the ring in px. Match the parent's radius. */
  borderRadius?: number;
  /** Overall effect opacity, 0–1. */
  strength?: number;
  /** Soft outward bloom under the ring, 0–1. 0 removes the bloom layer. */
  bloom?: number;
  /**
   * Color stops painted around the conic ring. Repeated at 0deg and 360deg
   * internally so the sweep is seamless.
   */
  colors?: string[];
}

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

/**
 * A conic-gradient ring that rotates continuously around the border.
 *
 * Distinct from border-beam: border-beam parks stationary gradients and slides
 * a single mask window over them (one traveling comet). Conic-border rotates
 * the whole multi-stop conic paint, so the entire ring is always lit and the
 * hues wheel around the perimeter. Technique: one registered @property angle
 * feeds `conic-gradient(from <angle>, …)`; a single interpolated value spins
 * the paint, confined to the ring by the double-mask idiom (padding +
 * content-box/exclude). An isolated blurred copy sits beneath as the bloom, so
 * the halo never softens the crisp stroke. Pauses offscreen via
 * IntersectionObserver; reduced motion parks a static gradient ring.
 * Absolutely positioned — parent needs position: relative.
 */
export function ConicBorder({
  duration = 8,
  borderWidth = 1.5,
  borderRadius = 12,
  strength = 1,
  bloom = 0.4,
  colors = ["#4D80E6", "#38bdf8", "#818cf8", "#2dd4bf", "#4D80E6"],
  className,
  style,
  ref: forwardedRef,
  ...props
}: ConicBorderProps) {
  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();
  }, []);

  // Ensure the wheel is seamless: force identical first/last stop.
  const stops =
    colors.length > 1
      ? [...colors, colors[0]]
      : [colors[0] ?? "#4D80E6", colors[0] ?? "#4D80E6"];
  const level = clamp01(strength);
  const bloomLevel = clamp01(bloom);

  const ringLayer = (padding: number): React.CSSProperties => ({
    position: "absolute",
    inset: 0,
    borderRadius,
    padding,
    background: `conic-gradient(from var(--conic-angle-${id}), ${stops.join(", ")})`,
    mask: "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
    WebkitMaskComposite: "xor",
    maskComposite: "exclude",
  });

  return (
    <>
      <style href={`paragon-conic-border-${id}`} precedence="paragon">{`
        @property --conic-angle-${id} {
          syntax: "<angle>";
          initial-value: 0deg;
          inherits: true;
        }
        @keyframes conic-spin-${id} {
          to { --conic-angle-${id}: 360deg; }
        }
        @media (prefers-reduced-motion: reduce) {
          [data-conic="${id}"] { animation: none !important; }
        }
      `}</style>
      <div
        aria-hidden
        data-conic={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: level,
          animation: `conic-spin-${id} ${duration}s linear infinite`,
          animationPlayState: inView ? "running" : "paused",
          ...style,
        }}
        {...props}
      >
        {bloomLevel > 0 && (
          <div
            style={{
              position: "absolute",
              inset: 0,
              filter: "blur(7px)",
              opacity: 0.6 * bloomLevel,
            }}
          >
            <div style={ringLayer(borderWidth + 1)} />
          </div>
        )}
        <div style={ringLayer(borderWidth)} />
      </div>
    </>
  );
}