Backgrounds

Gradient Mesh

A slow-drifting multi-hue mesh background driven by desynchronized oscillators, paused when offscreen.

Install

npx shadcn@latest add @paragon/gradient-mesh

gradient-mesh.tsx

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