bottega

backgrounds

Gradient Mesh

An ambient mesh-gradient background: 4-5 large blurred radial orbs that slowly drift, pulse, and breathe via staggered CSS keyframes, so the mesh never visibly loops. Orbs use normal compositing so the soft token-colored radials read on any --background (light or dark). Pure CSS animation by default (zero Motion); the optional `enter` prop adds a Motion fade-in. Decorative leaf: aria-hidden, pointer-events-none, fills its relative parent.

gradient-mesh

Install

1. Register the namespace (once per project):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/gradient-mesh

Usage

Usagetsx
<GradientMesh />

Props

PropTypeDefaultDescription
intensityenumsubtleOrb opacity scale: subtle=0.25, medium=0.45, vivid=0.65. Default 'subtle'.
speedenumslowDrift cycle duration: slow=40s, normal=22s, fast=12s. Default 'slow'.
blurnumber80Blur radius in px applied via filter:blur(). Keep <=120 for perf. Default 80.
enterbooleanfalseFade the layer in (opacity 0->1, 1.2s) on mount via Motion. Default false.

Source

gradient-mesh.tsxtsx
"use client";

import { useEffect, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
import type React from "react";
import styles from "./gradient-mesh.module.css";

export interface GradientMeshProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
  /** Orb opacity scale: subtle=0.25, medium=0.45, vivid=0.65. Default 'subtle'. */
  intensity?: "subtle" | "medium" | "vivid";
  /** Drift cycle duration: slow=40s, normal=22s, fast=12s. Default 'slow'. */
  speed?: "slow" | "normal" | "fast";
  /** Blur radius in px applied via filter:blur(). Keep <=120 for perf. Default 80. */
  blur?: number;
  /** Fade the layer in (opacity 0->1, 1.2s) on mount via Motion. Default false. */
  enter?: boolean;
}

const INTENSITY: Record<NonNullable<GradientMeshProps["intensity"]>, number> = {
  subtle: 0.25,
  medium: 0.45,
  vivid: 0.65,
};

const SPEED_SECONDS: Record<NonNullable<GradientMeshProps["speed"]>, number> = {
  slow: 40,
  normal: 22,
  fast: 12,
};

/**
 * Each orb's static geometry + animation offsets. Sizes are clamped against
 * container width; delays stagger so the mesh never visibly loops. Orbs 1 & 2
 * are the largest (and get will-change via :nth-child in CSS). Orb 5 (muted,
 * depth) only renders at medium/vivid intensity.
 */
const ORBS = [
  {
    color: "var(--gm-primary, var(--primary))",
    size: "clamp(200px, 40%, 500px)",
    top: "-8%",
    left: "-6%",
    dx: "18%",
    dy: "12%",
    scale: 1.15,
    blurAdjust: 0,
    delay: 0,
    breatheDelay: -3,
  },
  {
    color: "var(--gm-accent, var(--accent))",
    size: "clamp(200px, 30%, 380px)",
    top: "30%",
    left: "55%",
    dx: "-14%",
    dy: "-10%",
    scale: 1.2,
    blurAdjust: 12,
    delay: -5, // 5s gap vs primary — interference, not sync
    breatheDelay: -1,
  },
  {
    color: "var(--gm-primary, var(--primary))",
    size: "clamp(200px, 28%, 360px)",
    top: "55%",
    left: "10%",
    dx: "12%",
    dy: "-16%",
    scale: 0.9,
    blurAdjust: -10,
    delay: -11,
    breatheDelay: -4,
  },
  {
    color: "var(--gm-accent, var(--accent))",
    size: "clamp(200px, 26%, 340px)",
    top: "-4%",
    left: "60%",
    dx: "-10%",
    dy: "20%",
    scale: 1.1,
    blurAdjust: 6,
    delay: -8,
    breatheDelay: -6,
  },
  {
    // depth orb — medium/vivid only
    color: "var(--gm-muted, var(--muted))",
    size: "clamp(200px, 25%, 320px)",
    top: "40%",
    left: "32%",
    dx: "8%",
    dy: "8%",
    scale: 1.05,
    blurAdjust: 15,
    delay: -14,
    breatheDelay: -2,
  },
] as const;

export function GradientMesh({
  intensity = "subtle",
  speed = "slow",
  blur = 80,
  enter = false,
  className = "",
  style,
  ...props
}: GradientMeshProps) {
  const ref = useRef<HTMLDivElement>(null);

  // Mount-gate + null-safe reduce check (contract snippet). null => reduced.
  const [mounted, setMounted] = useState(false);
  useEffect(() => {
    setMounted(true);
  }, []);
  const prefersReducedMotion = useReducedMotion();
  const reduce = prefersReducedMotion === null || prefersReducedMotion;
  const animate = mounted && !reduce;

  // In-view pause: toggle a data-attr CSS reads to set animation-play-state.
  // Pure attribute toggle — no JS frame loop. Runs only after mount.
  const [inView, setInView] = useState(true);
  useEffect(() => {
    const el = ref.current;
    if (!el || typeof IntersectionObserver === "undefined") return;
    const io = new IntersectionObserver(
      ([entry]) => setInView(entry.isIntersecting),
      { rootMargin: "100px" },
    );
    io.observe(el);
    return () => io.disconnect();
  }, []);

  const opacity = INTENSITY[intensity];
  const baseDur = SPEED_SECONDS[speed];
  const showDepthOrb = intensity !== "subtle";
  const orbs = showDepthOrb ? ORBS : ORBS.slice(0, 4);

  const layer = (
    <div
      className={styles.layer}
      style={{ ["--gm-blur" as string]: `${blur}px` }}
    >
      {orbs.map((orb, i) => (
        <div
          key={i}
          className={styles.orb}
          style={
            {
              "--gm-color": orb.color,
              "--gm-size": orb.size,
              "--gm-top": orb.top,
              "--gm-left": orb.left,
              "--gm-dx": orb.dx,
              "--gm-dy": orb.dy,
              "--gm-scale": orb.scale,
              "--gm-opacity": opacity,
              "--gm-dur": `${baseDur}s`,
              "--gm-delay": `${orb.delay}s`,
              "--gm-breathe-delay": `${orb.breatheDelay}s`,
            } as React.CSSProperties
          }
        />
      ))}
    </div>
  );

  return (
    <div
      {...props}
      ref={ref}
      aria-hidden="true"
      data-inview={animate && !inView ? "false" : "true"}
      className={[styles.root, className].filter(Boolean).join(" ")}
      style={style}
    >
      {enter && animate ? (
        <motion.div
          className="absolute inset-0"
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          // 1.2s ease-out entrance — a bespoke scene timing (seconds), NOT
          // the --duration UI token (~300ms for CSS transitions).
          transition={{ duration: 1.2, ease: "easeOut" }}
        >
          {layer}
        </motion.div>
      ) : (
        layer
      )}
    </div>
  );
}

Dependencies

  • motion
  • @bottega/tokens