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-meshUsage
Usagetsx
<GradientMesh />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| intensity | enum | subtle | Orb opacity scale: subtle=0.25, medium=0.45, vivid=0.65. Default 'subtle'. |
| speed | enum | slow | Drift cycle duration: slow=40s, normal=22s, fast=12s. Default 'slow'. |
| blur | number | 80 | Blur radius in px applied via filter:blur(). Keep <=120 for perf. Default 80. |
| enter | boolean | false | Fade 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