"use client";
import * as React from "react";
export interface AuroraFieldProps extends React.ComponentPropsWithoutRef<"div"> {
/** Primary colour (any CSS colour). */
colorA?: string;
/** Secondary colour. */
colorB?: string;
/** Depth colour, used for the slow anchoring blob. */
colorC?: string;
/** Overall strength of the colour field, 0–1. */
intensity?: number;
/** Blur radius applied to each blob, in px. */
blur?: number;
/** Playback speed multiplier. 0 freezes the field. */
speed?: number;
}
type ColorSlot = "a" | "b" | "c";
interface Blob {
color: ColorSlot;
/** Position and size in % of the field. */
top: number;
left: number;
width: number;
height: number;
/** Drift path: [translateX %, translateY %, scale] per keyframe (of the blob's own size). */
path: [number, number, number][];
/** Base loop duration in ms at speed 1. */
duration: number;
}
// A hand-tuned composition: two large primary masses, one secondary sweep and a deep anchor.
const BLOBS: Blob[] = [
{ color: "c", top: 30, left: -20, width: 80, height: 90, duration: 38000, path: [[0, 0, 1], [10, -8, 1.08], [18, 6, 0.96], [4, 10, 1.04]] },
{ color: "a", top: -25, left: -10, width: 62, height: 70, duration: 26000, path: [[0, 0, 1], [22, 14, 1.12], [8, 28, 0.92], [-8, 10, 1.05]] },
{ color: "b", top: -10, left: 45, width: 60, height: 66, duration: 30000, path: [[0, 0, 1], [-18, 16, 0.94], [-30, 4, 1.1], [-10, -10, 1]] },
{ color: "a", top: 52, left: 52, width: 54, height: 58, duration: 34000, path: [[0, 0, 0.9], [-16, -20, 1.06], [6, -30, 1], [12, -8, 0.94]] },
];
const subscribeReducedMotion = (onChange: () => void) => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
};
/** SSR-safe `prefers-reduced-motion` subscription. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
const toTransform = ([x, y, s]: [number, number, number]) => `translate3d(${x}%, ${y}%, 0) scale(${s})`;
/**
* A fluid, slowly drifting colour atmosphere built from blurred radial blobs.
* Only `transform` is animated (GPU-composited); animation pauses while offscreen or
* in a hidden tab, and reduced-motion users get a still composition.
*/
export function AuroraField({
colorA = "#FF7448",
colorB = "#B6F3D1",
colorC = "#0E4F4C",
intensity = 0.7,
blur = 72,
speed = 1,
className = "",
style,
children,
...props
}: AuroraFieldProps) {
const rootRef = React.useRef<HTMLDivElement>(null);
const blobRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const reduced = usePrefersReducedMotion();
const still = reduced || speed <= 0;
const animationsRef = React.useRef<Animation[]>([]);
const speedRef = React.useRef(speed);
// Speed changes retime the running loops instead of restarting them.
React.useEffect(() => {
speedRef.current = speed;
for (const a of animationsRef.current) a.updatePlaybackRate(speed);
}, [speed]);
React.useEffect(() => {
const root = rootRef.current;
if (!root || still) return;
const animations = blobRefs.current.flatMap((el, i) => {
if (!el || typeof el.animate !== "function") return [];
const blob = BLOBS[i];
const keyframes = [...blob.path, blob.path[0]].map((p) => ({ transform: toTransform(p) }));
const anim = el.animate(keyframes, {
duration: blob.duration,
iterations: Infinity,
easing: "ease-in-out",
// Offset each blob so the composition never "resets" in sync.
delay: -i * 4200,
});
anim.playbackRate = speedRef.current;
return [anim];
});
animationsRef.current = animations;
let inView = true;
const sync = () => {
const run = inView && !document.hidden;
for (const a of animations) {
if (run && a.playState !== "running") a.play();
else if (!run && a.playState === "running") a.pause();
}
};
const io = new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting;
sync();
});
io.observe(root);
document.addEventListener("visibilitychange", sync);
return () => {
io.disconnect();
document.removeEventListener("visibilitychange", sync);
for (const a of animations) a.cancel();
animationsRef.current = [];
};
}, [still]);
const colors: Record<ColorSlot, string> = { a: colorA, b: colorB, c: colorC };
const opacity = Math.min(Math.max(intensity, 0), 1);
return (
<div
ref={rootRef}
className={`absolute inset-0 overflow-hidden ${className}`}
style={style}
{...props}
>
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
{BLOBS.map((blob, i) => (
<div
key={i}
ref={(el) => {
blobRefs.current[i] = el;
}}
// Screen blending on dark surfaces makes the colour read as light rather than paint.
className="absolute rounded-full will-change-transform dark:mix-blend-screen"
style={{
opacity,
top: `${blob.top}%`,
left: `${blob.left}%`,
width: `${blob.width}%`,
height: `${blob.height}%`,
// closest-side keeps a soft edge even with little blur, so blur can stay cheap.
background: `radial-gradient(closest-side, ${colors[blob.color]}, ${colors[blob.color]} 22%, transparent)`,
filter: blur > 0 ? `blur(${blur}px)` : undefined,
// A mid-path pose gives the still (reduced-motion) composition some life.
transform: still ? toTransform(blob.path[1]) : undefined,
}}
/>
))}
</div>
{children != null && <div className="relative h-full w-full">{children}</div>}
</div>
);
}