"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, Copy, Dices, Gauge, Grip, SlidersHorizontal, X } from "lucide-react";
import { cn } from "@/lib/utils";
export interface MeshPoint {
x: number;
y: number;
}
export interface MeshConfig {
seed: number;
colors: string[];
points: MeshPoint[];
speed: number;
}
export interface GradientMeshBgProps {
/** Seed for the generated palette and point layout. */
seed?: number;
/** Explicit colours (hex). Overrides the seeded palette. */
colors?: string[];
/** Number of colour points when using a seeded palette. */
count?: number;
/** Animation speed multiplier (0 = still). */
speed?: number;
/** Film grain overlay. */
grain?: boolean;
/** "auto" follows the `.dark` class on <html>. */
tone?: "auto" | "light" | "dark";
/** Show the in-place editor (drag points, pick colours, shuffle seed, copy config). */
editable?: boolean;
onEditableChange?: (editable: boolean) => void;
onChange?: (config: MeshConfig) => void;
children?: React.ReactNode;
className?: string;
}
function mulberry32(seed: number) {
return () => {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hslToHex(h: number, s: number, l: number) {
s /= 100;
l /= 100;
const k = (n: number) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
const hex = (x: number) => Math.round(x * 255).toString(16).padStart(2, "0");
return `#${hex(f(0))}${hex(f(8))}${hex(f(4))}`;
}
function hexToRgb(hex: string): [number, number, number] {
const h = hex.replace("#", "");
const f = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
const n = parseInt(f.slice(0, 6), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
/** Generates a harmonious palette + point layout from a seed. */
export function meshFromSeed(seed: number, count = 5, dark = false): Omit<MeshConfig, "speed"> {
const r = mulberry32(seed * 7919 + 13);
const base = r() * 360;
const offsets = [0, 28 + r() * 20, -(30 + r() * 25), 150 + r() * 40, 200 + r() * 50, 90 + r() * 30];
const colors = Array.from({ length: count }, (_, i) => {
const h = (base + offsets[i % offsets.length] + 360) % 360;
return dark ? hslToHex(h, 70 + r() * 20, 42 + r() * 16) : hslToHex(h, 80 + r() * 15, 67 + r() * 12);
});
const points = Array.from({ length: count }, (_, i) => {
const a = (i / count) * Math.PI * 2 + r() * 0.8;
const d = 0.18 + r() * 0.22;
return { x: 0.5 + Math.cos(a) * d * 1.3, y: 0.5 + Math.sin(a) * d };
});
return { seed, colors, points };
}
function useIsDark(tone: "auto" | "light" | "dark") {
const [dark, setDark] = React.useState(() =>
tone === "auto" ? typeof document !== "undefined" && document.documentElement.classList.contains("dark") : tone === "dark",
);
React.useEffect(() => {
const html = document.documentElement;
const upd = () => setDark(tone === "auto" ? html.classList.contains("dark") : tone === "dark");
queueMicrotask(upd);
if (tone !== "auto") return;
const mo = new MutationObserver(upd);
mo.observe(html, { attributes: true, attributeFilter: ["class"] });
return () => mo.disconnect();
}, [tone]);
return dark;
}
const GRAIN =
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")";
export function GradientMeshBg({
seed: seedProp = 7,
colors: colorsProp,
count = 5,
speed: speedProp = 1,
grain = true,
tone = "auto",
editable = false,
onEditableChange,
onChange,
children,
className,
}: GradientMeshBgProps) {
const reduce = useReducedMotion() ?? false;
const dark = useIsDark(tone);
const wrapRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const [seed, setSeed] = React.useState(seedProp);
const [speed, setSpeed] = React.useState(speedProp);
const generated = React.useMemo(() => meshFromSeed(seed, colorsProp?.length ?? count, dark), [seed, count, dark, colorsProp?.length]);
const [override, setOverride] = React.useState<{ colors?: string[]; points?: MeshPoint[] }>({});
const colors = override.colors ?? colorsProp ?? generated.colors;
const points = override.points ?? generated.points;
const [copied, setCopied] = React.useState(false);
const [dragging, setDragging] = React.useState<number | null>(null);
// Sync from props (and regenerate the palette on theme flips) without effects.
const [prev, setPrev] = React.useState({ seedProp, speedProp, dark });
if (prev.seedProp !== seedProp || prev.speedProp !== speedProp || prev.dark !== dark) {
setPrev({ seedProp, speedProp, dark });
if (prev.seedProp !== seedProp) setSeed(seedProp);
if (prev.speedProp !== speedProp) setSpeed(speedProp);
if (prev.dark !== dark) setOverride((o) => ({ points: o.points }));
}
const live = React.useRef({ colors, points, speed, dark });
React.useEffect(() => {
live.current = { colors, points, speed: reduce ? 0 : speed, dark };
}, [colors, points, speed, dark, reduce]);
const cfgCb = React.useRef(onChange);
React.useEffect(() => {
cfgCb.current = onChange;
}, [onChange]);
React.useEffect(() => {
cfgCb.current?.({ seed, colors, points, speed });
}, [seed, colors, points, speed]);
// Render loop (low-res canvas, upscaled smoothly by the browser).
React.useEffect(() => {
const canvas = canvasRef.current;
const wrap = wrapRef.current;
if (!canvas || !wrap) return;
const g = canvas.getContext("2d");
if (!g) return;
let W = 0;
let H = 0;
const SCALE = 6;
const ro = new ResizeObserver(([e]) => {
W = Math.max(8, Math.ceil(e.contentRect.width / SCALE));
H = Math.max(8, Math.ceil(e.contentRect.height / SCALE));
canvas.width = W;
canvas.height = H;
draw(tRef);
});
ro.observe(wrap);
let raf = 0;
let visible = true;
let tRef = 0;
let last = performance.now();
const draw = (t: number) => {
if (!W || !H) return;
const { colors: cols, points: pts, dark: dk } = live.current;
const first = hexToRgb(cols[0] ?? "#888888");
const mix = (c: number, towards: number, f: number) => Math.round(c + (towards - c) * f);
g.globalCompositeOperation = "source-over";
g.fillStyle = dk
? `rgb(${mix(first[0], 8, 0.82)},${mix(first[1], 8, 0.82)},${mix(first[2], 16, 0.82)})`
: `rgb(${mix(first[0], 255, 0.7)},${mix(first[1], 255, 0.7)},${mix(first[2], 255, 0.7)})`;
g.fillRect(0, 0, W, H);
const R = Math.max(W, H) * 0.62;
cols.forEach((hex, i) => {
const p = pts[i % pts.length];
const ph = i * 1.7;
const x = (p.x + Math.sin(t * (0.21 + i * 0.037) + ph) * 0.16) * W;
const y = (p.y + Math.cos(t * (0.17 + i * 0.041) + ph * 1.3) * 0.14) * H;
const [r, gg, b] = hexToRgb(hex);
const grad = g.createRadialGradient(x, y, 0, x, y, R * (0.8 + 0.25 * Math.sin(t * 0.3 + i)));
grad.addColorStop(0, `rgba(${r},${gg},${b},0.95)`);
grad.addColorStop(0.45, `rgba(${r},${gg},${b},0.45)`);
grad.addColorStop(1, `rgba(${r},${gg},${b},0)`);
g.fillStyle = grad;
g.fillRect(0, 0, W, H);
});
};
const loop = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
tRef += dt * live.current.speed;
draw(tRef);
raf = requestAnimationFrame(loop);
};
const start = () => {
cancelAnimationFrame(raf);
last = performance.now();
if (visible && !document.hidden) raf = requestAnimationFrame(loop);
};
const io = new IntersectionObserver(([e]) => {
visible = e.isIntersecting;
if (visible) start();
else cancelAnimationFrame(raf);
});
io.observe(wrap);
const onVis = () => (document.hidden ? cancelAnimationFrame(raf) : start());
document.addEventListener("visibilitychange", onVis);
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
};
}, []);
const setColor = (i: number, hex: string) => setOverride((o) => ({ ...o, colors: (o.colors ?? colors).map((c, j) => (j === i ? hex : c)) }));
const setPoint = (i: number, p: MeshPoint) =>
setOverride((o) => ({ ...o, points: (o.points ?? points).map((q, j) => (j === i ? { x: Math.min(1, Math.max(0, p.x)), y: Math.min(1, Math.max(0, p.y)) } : q)) }));
const copy = () => {
const cfg = { seed, colors, points: points.map((p) => ({ x: +p.x.toFixed(3), y: +p.y.toFixed(3) })), speed };
navigator.clipboard?.writeText(JSON.stringify(cfg, null, 2)).catch(() => {});
setCopied(true);
setTimeout(() => setCopied(false), 1400);
};
return (
<div ref={wrapRef} className={cn("relative isolate overflow-hidden", className)}>
<canvas ref={canvasRef} aria-hidden className="absolute inset-0 -z-10 size-full" />
{grain && <div aria-hidden className="pointer-events-none absolute inset-0 -z-10 opacity-[0.18] mix-blend-overlay" style={{ backgroundImage: GRAIN }} />}
{children}
<AnimatePresence>
{editable && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute inset-0 z-10">
<div className="absolute inset-0 bg-black/5 dark:bg-black/20" />
{points.slice(0, colors.length).map((p, i) => (
<button
key={i}
type="button"
aria-label={`Colour point ${i + 1}. Drag or use arrow keys to move.`}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
setDragging(i);
}}
onPointerMove={(e) => {
if (dragging !== i || !wrapRef.current) return;
const r = wrapRef.current.getBoundingClientRect();
setPoint(i, { x: (e.clientX - r.left) / r.width, y: (e.clientY - r.top) / r.height });
}}
onPointerUp={() => setDragging(null)}
onKeyDown={(e) => {
const d = e.shiftKey ? 0.1 : 0.02;
const m: Record<string, [number, number]> = { ArrowLeft: [-d, 0], ArrowRight: [d, 0], ArrowUp: [0, -d], ArrowDown: [0, d] };
const v = m[e.key];
if (!v) return;
e.preventDefault();
setPoint(i, { x: p.x + v[0], y: p.y + v[1] });
}}
className={cn(
"absolute size-7 -translate-x-1/2 -translate-y-1/2 touch-none rounded-full border-[3px] border-white shadow-[0_2px_10px_rgba(0,0,0,0.35)] outline-none focus-visible:ring-4 focus-visible:ring-white/60",
dragging === i ? "scale-110 cursor-grabbing" : "cursor-grab",
)}
style={{ left: `${p.x * 100}%`, top: `${p.y * 100}%`, background: colors[i] }}
>
<Grip className="m-auto size-3 text-white/90 drop-shadow" />
</button>
))}
<div className="absolute inset-x-3 bottom-3 flex flex-wrap items-center gap-2 rounded-2xl border bg-background/90 p-2 text-foreground shadow-xl backdrop-blur sm:inset-x-auto sm:left-1/2 sm:w-max sm:-translate-x-1/2 sm:flex-nowrap">
<div className="flex items-center gap-1" role="group" aria-label="Colours">
{colors.map((c, i) => (
<label key={i} className="relative size-7 cursor-pointer overflow-hidden rounded-full border shadow-sm focus-within:ring-2 focus-within:ring-ring" style={{ background: c }}>
<span className="sr-only">Colour {i + 1}</span>
<input type="color" value={c} onChange={(e) => setColor(i, e.target.value)} className="absolute inset-0 size-full cursor-pointer opacity-0" />
</label>
))}
</div>
<span className="h-6 w-px bg-border" />
<button
type="button"
onClick={() => {
setSeed((s) => (s * 16807 + 11) % 99991);
setOverride({});
}}
className="inline-flex h-8 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<Dices className="size-4" /> <span className="tabular-nums">#{seed}</span>
</button>
<label className="inline-flex h-8 items-center gap-1.5 px-1 text-xs text-muted-foreground">
<Gauge className="size-4" />
<span className="sr-only">Speed</span>
<input
type="range"
min={0}
max={3}
step={0.1}
value={speed}
onChange={(e) => setSpeed(Number(e.target.value))}
className="w-16 accent-[var(--color-primary)] sm:w-20"
/>
</label>
<button
type="button"
onClick={copy}
className="inline-flex h-8 items-center gap-1.5 rounded-full bg-foreground px-3 text-xs font-medium text-background transition hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />} {copied ? "Copied" : "Copy config"}
</button>
{onEditableChange && (
<button
type="button"
aria-label="Close editor"
onClick={() => onEditableChange(false)}
className="grid size-8 place-items-center rounded-full transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<X className="size-4" />
</button>
)}
</div>
</motion.div>
)}
</AnimatePresence>
{onEditableChange && !editable && (
<button
type="button"
onClick={() => onEditableChange(true)}
className="absolute top-3 right-3 z-10 inline-flex h-8 items-center gap-1.5 rounded-full border border-white/30 bg-white/60 px-3 text-xs font-medium text-neutral-900 shadow-sm backdrop-blur transition hover:bg-white/80 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none dark:border-white/15 dark:bg-black/30 dark:text-white dark:hover:bg-black/45"
>
<SlidersHorizontal className="size-3.5" /> Edit gradient
</button>
)}
</div>
);
}