A slow-drifting multi-hue mesh background driven by desynchronized oscillators, paused when offscreen.
npx shadcn@latest add @paragon/gradient-mesh"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface GradientMeshProps extends React.ComponentProps<"div"> {
/** Seconds for the fastest blob's drift cycle; slower blobs derive from it. */
duration?: number;
/** Blob hues. One blurred radial blob renders per color. */
colors?: string[];
/** Overall effect opacity, 0–1. */
strength?: number;
/** Blob blur radius in px. Clamped to 20 (Safari filter ceiling). */
blur?: number;
/** Render the mesh as a frozen composition with no motion. */
static?: boolean;
}
/** Deterministic blob placement — corners first, then center fill. */
const BLOB_LAYOUT = [
{ left: "-12%", top: "-18%", size: "62%" },
{ left: "48%", top: "-14%", size: "58%" },
{ left: "-8%", top: "42%", size: "60%" },
{ left: "44%", top: "38%", size: "64%" },
{ left: "18%", top: "12%", size: "54%" },
];
/**
* Desynchronized oscillator periods (border-beam idiom): each blob runs the
* same drift path at a period offset of ×1.3 / ×1.7 / …, so the composition
* never visibly repeats.
*/
const PERIODS = [1, 1.3, 1.7, 2.21, 2.87];
/**
* A slow-drifting multi-hue mesh background.
*
* Technique: blurred radial blobs are parked at fixed positions; only a
* transform (translate + scale) keyframe drifts each one, with periods
* desynchronized ×1.3/×1.7 and alternating directions so the motion reads
* organic rather than looped. Blur stays ≤ 20px for Safari. Pauses offscreen
* via IntersectionObserver and freezes to the static composition under
* prefers-reduced-motion. Absolutely positioned — parent needs
* position: relative (and usually overflow-hidden + a radius).
*/
export function GradientMesh({
duration = 18,
colors = ["#818cf8", "#38bdf8", "#c084fc", "#f472b6"],
strength = 0.5,
blur = 18,
static: isStatic = false,
className,
style,
...props
}: GradientMeshProps) {
const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
const ref = React.useRef<HTMLDivElement>(null);
const [offscreen, setOffscreen] = React.useState(false);
React.useEffect(() => {
if (isStatic) return;
const node = ref.current;
if (!node) return;
const observer = new IntersectionObserver(([entry]) => {
setOffscreen(!entry.isIntersecting);
});
observer.observe(node);
return () => observer.disconnect();
}, [isStatic]);
const blurPx = Math.min(Math.max(blur, 0), 20);
return (
<div
ref={ref}
aria-hidden
data-slot="gradient-mesh"
data-mesh={id}
data-paused={offscreen || undefined}
className={cn(
"pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]",
className,
)}
style={{ opacity: strength, ...style }}
{...props}
>
{!isStatic && (
<style href={`paragon-gradient-mesh-${id}`} precedence="paragon">{`
@keyframes mesh-drift-${id} {
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
25% { transform: translate3d(7%, -9%, 0) scale(1.08); }
50% { transform: translate3d(-5%, 6%, 0) scale(0.94); }
75% { transform: translate3d(-8%, -4%, 0) scale(1.05); }
}
[data-mesh="${id}"][data-paused] > div {
animation-play-state: paused;
}
@media (prefers-reduced-motion: reduce) {
[data-mesh="${id}"] > div {
animation: none !important;
}
}
`}</style>
)}
{colors.map((color, i) => {
const layout = BLOB_LAYOUT[i % BLOB_LAYOUT.length];
const period = duration * PERIODS[i % PERIODS.length];
return (
<div
key={`${color}-${i}`}
style={{
position: "absolute",
left: layout.left,
top: layout.top,
width: layout.size,
aspectRatio: "1 / 1",
borderRadius: "50%",
background: `radial-gradient(circle closest-side, ${color}, transparent 72%)`,
filter: `blur(${blurPx}px)`,
willChange: isStatic ? undefined : "transform",
animation: isStatic
? undefined
: `mesh-drift-${id} ${period}s var(--ease-in-out) infinite`,
animationDelay: isStatic
? undefined
: `${(-period * ((i * 0.37) % 1)).toFixed(2)}s`,
animationDirection: i % 2 === 1 ? "reverse" : undefined,
}}
/>
);
})}
</div>
);
}