A conic-gradient ring that rotates continuously around the border, wheeling its hues around the perimeter with an isolated bloom.
npx shadcn@latest add @paragon/conic-border"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>
</>
);
}