Fazekit

Code

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

More in Backgrounds

View all →