Backgrounds

Aurora

Soft aurora ribbons that drift slowly, rendered as frozen blurred layers animated only by hue-rotate for performance.

Install

npx shadcn@latest add @paragon/aurora

aurora.tsx

"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>
    </>
  );
}