Web InnoventixFreeCode

Mesh Gradient Background

Original · free

animated mesh gradient background

byWeb InnoventixReact + Tailwind
bgxmeshgradientbackgrounds
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/bgx-mesh-gradient.json
bgx-mesh-gradient.tsx
"use client";

import { useId, useRef, useState, type KeyboardEvent } from "react";
import { motion, useReducedMotion } from "motion/react";

type PaletteId = "aurora" | "ember" | "meadow";

interface Palette {
  id: PaletteId;
  label: string;
  hint: string;
  colors: [string, string, string, string, string, string];
}

interface Slot {
  top: string;
  left: string;
  size: string;
  dx: number;
  dy: number;
  scale: number;
  dur: number;
}

const PALETTES: Palette[] = [
  {
    id: "aurora",
    label: "Aurora",
    hint: "indigo · violet · sky",
    colors: [
      "bg-indigo-500",
      "bg-violet-500",
      "bg-sky-400",
      "bg-emerald-400",
      "bg-indigo-400",
      "bg-violet-600",
    ],
  },
  {
    id: "ember",
    label: "Ember",
    hint: "rose · amber · violet",
    colors: [
      "bg-rose-500",
      "bg-amber-400",
      "bg-rose-400",
      "bg-amber-500",
      "bg-violet-500",
      "bg-rose-600",
    ],
  },
  {
    id: "meadow",
    label: "Meadow",
    hint: "emerald · sky · indigo",
    colors: [
      "bg-emerald-500",
      "bg-sky-400",
      "bg-emerald-400",
      "bg-sky-500",
      "bg-indigo-400",
      "bg-emerald-300",
    ],
  },
];

const SLOTS: Slot[] = [
  { top: "-12%", left: "-8%", size: "58%", dx: 70, dy: 52, scale: 1.16, dur: 18 },
  { top: "6%", left: "56%", size: "52%", dx: -82, dy: 60, scale: 1.1, dur: 23 },
  { top: "52%", left: "4%", size: "48%", dx: 92, dy: -48, scale: 1.2, dur: 20 },
  { top: "-6%", left: "38%", size: "46%", dx: -58, dy: 82, scale: 1.12, dur: 25 },
  { top: "56%", left: "60%", size: "50%", dx: -74, dy: -62, scale: 1.18, dur: 19 },
  { top: "26%", left: "26%", size: "40%", dx: 52, dy: 44, scale: 1.08, dur: 27 },
];

const NOISE =
  "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")";

export default function BgxMeshGradient() {
  const prefersReduced = useReducedMotion();
  const uid = useId();
  const softId = `${uid}-soft`;
  const speedId = `${uid}-speed`;

  const [paletteId, setPaletteId] = useState<PaletteId>("aurora");
  const [blur, setBlur] = useState(58);
  const [speed, setSpeed] = useState(1);
  const [playing, setPlaying] = useState(true);
  const [grain, setGrain] = useState(true);

  const paletteRefs = useRef<Array<HTMLButtonElement | null>>([]);

  const palette = PALETTES.find((p) => p.id === paletteId) ?? PALETTES[0];
  const animating = playing && !prefersReduced;
  const grainOn = grain;

  function onPaletteKey(e: KeyboardEvent<HTMLDivElement>) {
    const idx = PALETTES.findIndex((p) => p.id === paletteId);
    let next = idx;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") {
      next = (idx + 1) % PALETTES.length;
    } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
      next = (idx - 1 + PALETTES.length) % PALETTES.length;
    } else if (e.key === "Home") {
      next = 0;
    } else if (e.key === "End") {
      next = PALETTES.length - 1;
    } else {
      return;
    }
    e.preventDefault();
    setPaletteId(PALETTES[next].id);
    paletteRefs.current[next]?.focus();
  }

  return (
    <section className="relative isolate w-full overflow-hidden bg-slate-50 px-6 py-16 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:px-8 sm:py-24 min-h-[44rem]">
      <style>{`
        @keyframes bgxmesh-grain {
          0%, 100% { transform: translate3d(0, 0, 0); }
          25% { transform: translate3d(-3%, 2%, 0); }
          50% { transform: translate3d(2%, -2%, 0); }
          75% { transform: translate3d(-1%, 3%, 0); }
        }
        .bgxmesh-grain-anim { animation: bgxmesh-grain 7.5s steps(5) infinite; }
        @media (prefers-reduced-motion: reduce) {
          .bgxmesh-grain-anim { animation: none !important; }
        }
      `}</style>

      {/* Animated mesh gradient field */}
      <div aria-hidden className="pointer-events-none absolute inset-0 z-0 overflow-hidden">
        {SLOTS.map((slot, i) => (
          <motion.div
            key={i}
            className={`absolute aspect-square rounded-full opacity-70 mix-blend-multiply will-change-transform dark:opacity-60 dark:mix-blend-screen ${palette.colors[i]}`}
            style={{ top: slot.top, left: slot.left, width: slot.size, filter: `blur(${blur}px)` }}
            animate={
              animating
                ? {
                    x: [0, slot.dx, -slot.dx * 0.5, 0],
                    y: [0, slot.dy, slot.dy * 0.4, 0],
                    scale: [1, slot.scale, 1],
                  }
                : { x: 0, y: 0, scale: 1 }
            }
            transition={
              animating
                ? { duration: slot.dur / speed, repeat: Infinity, ease: "easeInOut" }
                : { duration: 0.6, ease: "easeOut" }
            }
          />
        ))}
      </div>

      {/* Film grain */}
      {grainOn ? (
        <div
          aria-hidden
          className={`pointer-events-none absolute inset-0 z-0 opacity-40 mix-blend-overlay dark:opacity-30 dark:mix-blend-soft-light ${
            animating ? "bgxmesh-grain-anim" : ""
          }`}
          style={{ backgroundImage: NOISE, backgroundSize: "160px 160px" }}
        />
      ) : null}

      {/* Readability scrim (keeps copy legible over vivid fields) */}
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 z-0 bg-gradient-to-r from-slate-50 via-slate-50/50 to-transparent dark:from-slate-950 dark:via-slate-950/45"
      />

      {/* Edge vignette */}
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 z-0 block dark:hidden"
        style={{
          background:
            "radial-gradient(135% 120% at 50% -10%, rgba(248,250,252,0) 45%, rgba(248,250,252,0.9) 100%)",
        }}
      />
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 z-0 hidden dark:block"
        style={{
          background:
            "radial-gradient(135% 120% at 50% -10%, rgba(2,6,23,0) 42%, rgba(2,6,23,0.92) 100%)",
        }}
      />

      {/* Content */}
      <div className="relative z-10 mx-auto grid max-w-6xl items-center gap-10 lg:grid-cols-2 lg:gap-16">
        <div>
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-300/70 bg-white/60 px-3 py-1 text-xs font-medium tracking-wide text-slate-600 backdrop-blur dark:border-white/10 dark:bg-white/5 dark:text-slate-300">
            <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" aria-hidden>
              <path
                d="M12 2.5l1.9 5.1 5.1 1.9-5.1 1.9L12 16.5l-1.9-5.1L5 9.5l5.1-1.9L12 2.5z"
                fill="currentColor"
              />
            </svg>
            Backgrounds · Mesh gradient
          </span>

          <h2 className="mt-5 text-balance text-4xl font-semibold leading-[1.05] tracking-tight sm:text-5xl">
            A backdrop that moves like light through frosted glass.
          </h2>

          <p className="mt-5 max-w-lg text-pretty text-base leading-relaxed text-slate-600 dark:text-slate-300 sm:text-lg">
            Six blurred color fields drift on independent timers to build a living mesh gradient.
            Retune the palette, soften the edges, or slow it right down. No canvas, no WebGL — just
            CSS blur and transforms, and it holds up in light and dark.
          </p>

          <dl className="mt-8 flex flex-wrap gap-x-8 gap-y-4">
            <div>
              <dt className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">
                Render cost
              </dt>
              <dd className="mt-1 text-sm font-semibold text-slate-900 dark:text-slate-100">
                6 layers · GPU compositing
              </dd>
            </div>
            <div>
              <dt className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">
                Motion
              </dt>
              <dd className="mt-1 text-sm font-semibold text-slate-900 dark:text-slate-100">
                Honors reduced-motion
              </dd>
            </div>
          </dl>
        </div>

        {/* Control panel */}
        <div className="rounded-2xl border border-slate-200/80 bg-white/70 p-6 shadow-xl shadow-slate-900/5 backdrop-blur-md dark:border-white/10 dark:bg-slate-900/50 dark:shadow-black/30 sm:p-7">
          <div className="flex items-center justify-between gap-4">
            <div>
              <h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
                Composition
              </h3>
              <p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
                Live controls for the field behind you
              </p>
            </div>
            <button
              type="button"
              aria-pressed={animating}
              disabled={!!prefersReduced}
              onClick={() => setPlaying((v) => !v)}
              className="inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-700 shadow-sm transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/15 dark:bg-white/5 dark:text-slate-200 dark:hover:bg-white/10 dark:focus-visible:ring-offset-slate-900"
            >
              {animating ? (
                <svg viewBox="0 0 24 24" className="h-4 w-4" fill="currentColor" aria-hidden>
                  <rect x="6" y="5" width="4" height="14" rx="1" />
                  <rect x="14" y="5" width="4" height="14" rx="1" />
                </svg>
              ) : (
                <svg viewBox="0 0 24 24" className="h-4 w-4" fill="currentColor" aria-hidden>
                  <path d="M8 5.5v13a1 1 0 0 0 1.5.87l10-6.5a1 1 0 0 0 0-1.74l-10-6.5A1 1 0 0 0 8 5.5z" />
                </svg>
              )}
              <span>
                {prefersReduced ? "Motion reduced" : animating ? "Pause" : "Play"}
              </span>
            </button>
          </div>

          {/* Palette radiogroup */}
          <div className="mt-6">
            <span className="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
              Palette
            </span>
            <div
              role="radiogroup"
              aria-label="Color palette"
              onKeyDown={onPaletteKey}
              className="mt-2 grid grid-cols-3 gap-2"
            >
              {PALETTES.map((p, i) => {
                const selected = p.id === paletteId;
                return (
                  <button
                    key={p.id}
                    ref={(el) => {
                      paletteRefs.current[i] = el;
                    }}
                    type="button"
                    role="radio"
                    aria-checked={selected}
                    tabIndex={selected ? 0 : -1}
                    onClick={() => setPaletteId(p.id)}
                    className={`group rounded-xl border px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
                      selected
                        ? "border-indigo-500 bg-indigo-50 dark:border-indigo-400 dark:bg-indigo-500/10"
                        : "border-slate-200 bg-white/40 hover:border-slate-300 dark:border-white/10 dark:bg-white/5 dark:hover:border-white/20"
                    }`}
                  >
                    <span className="flex items-center gap-1">
                      {p.colors.slice(0, 4).map((c, j) => (
                        <span
                          key={j}
                          className={`h-3 w-3 rounded-full ring-1 ring-black/10 dark:ring-white/10 ${c}`}
                        />
                      ))}
                    </span>
                    <span className="mt-2 block text-sm font-semibold text-slate-900 dark:text-slate-100">
                      {p.label}
                    </span>
                    <span className="block text-[11px] text-slate-500 dark:text-slate-400">
                      {p.hint}
                    </span>
                  </button>
                );
              })}
            </div>
          </div>

          {/* Softness slider */}
          <div className="mt-6">
            <div className="flex items-center justify-between">
              <label htmlFor={softId} className="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
                Softness
              </label>
              <span className="tabular-nums text-xs font-semibold text-slate-700 dark:text-slate-300">
                {blur}px blur
              </span>
            </div>
            <input
              id={softId}
              type="range"
              min={20}
              max={100}
              step={1}
              value={blur}
              onChange={(e) => setBlur(Number(e.target.value))}
              className="mt-2 w-full cursor-pointer accent-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:accent-indigo-400 dark:focus-visible:ring-offset-slate-900"
            />
          </div>

          {/* Speed slider */}
          <div className="mt-5">
            <div className="flex items-center justify-between">
              <label htmlFor={speedId} className="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
                Drift speed
              </label>
              <span className="tabular-nums text-xs font-semibold text-slate-700 dark:text-slate-300">
                {speed.toFixed(2)}×
              </span>
            </div>
            <input
              id={speedId}
              type="range"
              min={0.25}
              max={2}
              step={0.25}
              value={speed}
              disabled={!!prefersReduced}
              onChange={(e) => setSpeed(Number(e.target.value))}
              className="mt-2 w-full cursor-pointer accent-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:accent-indigo-400 dark:focus-visible:ring-offset-slate-900"
            />
          </div>

          {/* Grain switch */}
          <div className="mt-6 flex items-center justify-between border-t border-slate-200/70 pt-5 dark:border-white/10">
            <span className="text-sm font-medium text-slate-700 dark:text-slate-200">
              Film grain
            </span>
            <button
              type="button"
              role="switch"
              aria-checked={grain}
              aria-label="Film grain overlay"
              onClick={() => setGrain((v) => !v)}
              className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
                grain ? "bg-indigo-500" : "bg-slate-300 dark:bg-slate-700"
              }`}
            >
              <span
                className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${
                  grain ? "translate-x-5" : "translate-x-0.5"
                }`}
              />
            </button>
          </div>

          {prefersReduced ? (
            <p className="mt-4 text-xs leading-relaxed text-slate-500 dark:text-slate-400">
              Your system requests reduced motion, so the field is held still and drift controls are
              off. Palette and softness still apply.
            </p>
          ) : null}
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →