Soft aurora ribbons that drift slowly, rendered as frozen blurred layers animated only by hue-rotate for performance.
npx shadcn@latest add @paragon/aurora"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface AuroraProps extends React.ComponentProps<"div"> {
/** Seconds per drift cycle for the slowest ribbon. */
duration?: number;
/** Ribbon colors. Each ribbon blends a pair from this list. */
colors?: string[];
/** Overall effect opacity, 0–1. */
strength?: number;
/** Blur radius in px — applied once to each ribbon, never animated. */
blur?: number;
/** Render the ribbons frozen in place (no drift, color intact). */
static?: boolean;
}
/** Geometry for each ribbon: frozen layout, desynchronized periods. */
const RIBBONS = [
{ top: "-18%", left: "-16%", width: "88%", height: "56%", tilt: -14, pair: [0, 1], period: 1, phase: 0, reverse: false },
{ top: "-6%", left: "24%", width: "82%", height: "50%", tilt: 10, pair: [2, 3], period: 1.3, phase: 0.4, reverse: true },
{ top: "14%", left: "-2%", width: "96%", height: "46%", tilt: -6, pair: [1, 2], period: 1.7, phase: 0.7, reverse: false },
] as const;
/**
* Soft aurora ribbons drifting across the parent surface.
*
* Technique (perf ladder): each ribbon is a gradient blurred ONCE via a
* static filter on the inner layer. Only the wrapper animates — a slow
* transform drift plus hue-rotate — so the expensive blur is never
* recomputed per frame; the color shifts for the cost of a color matrix.
* Periods are desynchronized (×1, ×1.3, ×1.7), the loop pauses offscreen,
* and reduced motion freezes the ribbons with their color intact.
* Absolutely positioned — parent needs position: relative.
*/
export function Aurora({
duration = 18,
colors = ["#34d399", "#22d3ee", "#818cf8", "#c084fc"],
strength = 1,
blur = 20,
static: isStatic = false,
className,
style,
ref: forwardedRef,
...props
}: AuroraProps) {
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 || isStatic) return;
const observer = new IntersectionObserver(([entry]) => {
setInView(entry?.isIntersecting ?? true);
});
observer.observe(node);
return () => observer.disconnect();
}, [isStatic]);
return (
<>
<style href={`paragon-aurora-${id}`} precedence="paragon">{`
@keyframes aurora-drift-${id} {
0%, 100% {
transform: translate3d(-5%, -2%, 0) rotate(var(--aurora-tilt)) scaleX(1);
filter: hue-rotate(0deg);
}
50% {
transform: translate3d(5%, 3%, 0) rotate(calc(var(--aurora-tilt) + 5deg)) scaleX(1.08);
filter: hue-rotate(30deg);
}
}
@media (prefers-reduced-motion: reduce) {
[data-aurora="${id}"] [data-aurora-ribbon] { animation: none !important; }
}
`}</style>
<div
aria-hidden
data-aurora={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 overflow-hidden opacity-60 dark:opacity-50",
className,
)}
style={
{
maskImage:
"linear-gradient(to bottom, white 45%, transparent 96%)",
...style,
} as React.CSSProperties
}
{...props}
>
{RIBBONS.map((ribbon, i) => {
const from = colors[ribbon.pair[0] % colors.length];
const to = colors[ribbon.pair[1] % colors.length];
const period = duration * ribbon.period;
return (
<div
key={i}
data-aurora-ribbon
style={
{
position: "absolute",
top: ribbon.top,
left: ribbon.left,
width: ribbon.width,
height: ribbon.height,
"--aurora-tilt": `${ribbon.tilt}deg`,
transform: `rotate(${ribbon.tilt}deg)`,
willChange: isStatic ? undefined : "transform, filter",
animation: isStatic
? undefined
: `aurora-drift-${id} ${period}s var(--ease-in-out) infinite`,
animationDelay: isStatic
? undefined
: `${-period * ribbon.phase}s`,
animationDirection: ribbon.reverse ? "reverse" : "normal",
animationPlayState: inView ? "running" : "paused",
} as React.CSSProperties
}
>
{/* Frozen layer: blurred once, never re-blurred per frame. */}
<div
style={{
position: "absolute",
inset: 0,
borderRadius: 9999,
background: `linear-gradient(90deg, transparent 6%, ${from} 32%, ${to} 64%, transparent 94%)`,
filter: `blur(${blur}px)`,
opacity: strength,
}}
/>
</div>
);
})}
</div>
</>
);
}