← Components

Mesh Gradient

Soft fields of colour that drift, swirl and blend, with fine film grain.

Backgrounds · FreeInstall ↓
Open preview ↗
Loading the demoNew tab ↗

Customize

0.40
0.60
0.060
0.50
Your code
<MeshGradient />

Changes show live and stay in the address, so a link keeps them.

Code

"use client";

import { useEffect, useRef, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from "react";

// A calm, painterly mesh gradient. One small WebGL1 fragment shader: four or five colour
// fields drift on slow loops of their own, the space they live in is bent by two layers of
// noise (domain warping) so the edges swirl like wet paint, and a fine film grain sits on top.
// The colours lean towards the cursor. No libraries, one full-screen triangle, one draw a frame.

export type MeshGradientPreset = "auto" | "aurora" | "dusk" | "lime-ink" | "paper";

export type MeshGradientProps = {
  /** Four or five hex colours, or a preset name. "auto" is aurora, tuned for the page's light or dark theme. */
  colors?: readonly string[] | MeshGradientPreset;
  /** How fast the colours drift, from 0 (still) to 1. */
  speed?: number;
  /** How much the noise bends and swirls the colour fields, from 0 (soft blobs) to 1. */
  warp?: number;
  /** Film grain strength, from 0 to 0.2. It also hides colour banding. */
  grain?: number;
  /** How much the colours lean towards the cursor (or finger), from 0 to 1. */
  cursorInfluence?: number;
  /** Which tuning the presets use. "auto" follows the page: data-theme, a dark class, or the system setting. */
  theme?: "auto" | "light" | "dark";
  /** Extra classes for the outer box. Give it a size (it fills it). */
  className?: string;
  /** Content shown over the gradient. It can use var(--mesh-ink) for a text colour that reads on it. */
  children?: ReactNode;
};

// ---------- palettes, each tuned once for light pages and once for dark ----------

type NamedPreset = Exclude<MeshGradientPreset, "auto">;

// Dark tunings keep the base deep, because light text sits on them. A bright colour may still
// appear, but only as the "accent" field: a tighter, smaller glow that drifts through the deep
// colours instead of washing over them. Light tunings are the mirror.
type Tuning = { colors: string[]; accent?: number };
const presets: Record<NamedPreset, { light: Tuning; dark: Tuning }> = {
  // The owner's favourite: deep aurora teal into mint
  aurora: {
    light: { colors: ["#e6fbf6", "#99f6e4", "#5eead4", "#0f766e", "#f4fbf8"] },
    dark: { colors: ["#021c1a", "#0f766e", "#0a4d47", "#14b8a6", "#99f6e4"], accent: 4 },
  },
  dusk: {
    light: { colors: ["#fff1e8", "#f9a8d4", "#fdba74", "#c4b5fd", "#fde4ef"] },
    dark: { colors: ["#140f2e", "#5b21b6", "#be185d", "#c2410c", "#1e1b4b"] },
  },
  // DiMaac's paper, ink and lime. On dark, ink leads and the brand lime is one tight accent
  // bloom: spread wide, lime mixed into black reads as olive, so it is kept small and bright
  "lime-ink": {
    light: { colors: ["#f7f4ee", "#e9e4d8", "#d6fd51", "#f1f7c9", "#c8c1b0"] },
    dark: { colors: ["#0d0c0a", "#161512", "#d6fd51", "#1c1a16", "#24221d"], accent: 2 },
  },
  paper: {
    light: { colors: ["#faf9f6", "#ebe6dc", "#d8d1c3", "#f3f0e9", "#bfb7a7"] },
    dark: { colors: ["#100f0e", "#24211d", "#3d3832", "#161512", "#5b554b"] },
  },
};

// ---------- shaders ----------

const vertexShader = `
attribute vec2 position;
varying vec2 uv;
void main() {
  uv = position * 0.5 + 0.5;
  gl_Position = vec4(position, 0.0, 1.0);
}`;

const fragmentShader = `
precision highp float;
varying vec2 uv;
uniform vec3 colors[5];
uniform float count;
uniform float time;
uniform float aspect;
uniform float warp;
uniform float grain;
uniform vec2 pointer;
uniform float pull;
uniform float seed;
uniform float lead;
uniform float accent;

float random(vec2 point) {
  return fract(sin(dot(point, vec2(12.9898, 78.233))) * 43758.5453);
}
// A cleaner hash for the grain, so it reads as film and not as a pattern
float speckle(vec2 point) {
  vec3 p = fract(vec3(point.xyx) * 0.1031);
  p += dot(p, p.yzx + 33.33);
  return fract((p.x + p.y) * p.z);
}
float noise(vec2 point) {
  vec2 cell = floor(point);
  vec2 f = fract(point);
  vec2 blend = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
  float a = random(cell);
  float b = random(cell + vec2(1.0, 0.0));
  float c = random(cell + vec2(0.0, 1.0));
  float d = random(cell + vec2(1.0, 1.0));
  return mix(mix(a, b, blend.x), mix(c, d, blend.x), blend.y);
}
// A few layers of noise, each smaller and turned, for an organic, non-grid look
float fbm(vec2 point) {
  float total = 0.0;
  float amount = 0.5;
  mat2 turn = mat2(0.8, 0.6, -0.6, 0.8);
  for (int i = 0; i < 4; i++) {
    total += amount * noise(point);
    point = turn * point * 2.03 + 3.1;
    amount *= 0.5;
  }
  return total;
}

void main() {
  // Measured in shorter-sides, so the fields keep their size on a tall phone as on a wide screen
  vec2 size = vec2(aspect, 1.0) / min(aspect, 1.0);
  vec2 point = uv * size;
  vec2 cursor = pointer * size;

  // The cursor gently draws the space around it in
  vec2 toCursor = cursor - point;
  point += toCursor * exp(-dot(toCursor, toCursor) * 3.0) * pull * 0.3;

  // Domain warping: noise moves the point, then noise of that moves it again
  vec2 q = vec2(fbm(point * 1.2 + vec2(0.0, time * 0.05)), fbm(point * 1.2 + vec2(5.2, 1.3) - time * 0.04));
  vec2 r = vec2(fbm(point * 1.4 + q * 2.2 + vec2(1.7, 9.2) + time * 0.03), fbm(point * 1.4 + q * 2.2 + vec2(8.3, 2.8) - time * 0.035));
  vec2 bent = point + (r - 0.5) * warp * 1.4;

  // Each colour is a soft field around a centre that drifts on its own slow loop.
  // Mixing happens in (roughly) linear light so blends glow instead of going muddy.
  vec3 sum = vec3(0.0);
  float total = 0.0;
  for (int i = 0; i < 5; i++) {
    if (float(i) >= count) break;
    float f = float(i);
    vec2 centre = size * vec2(
      0.5 + cos(time * (0.21 + f * 0.047) + f * 2.4) * 0.45,
      0.5 + sin(time * (0.17 + f * 0.039) + f * 1.7) * 0.42
    );
    centre += (cursor - centre) * pull * 0.22;
    vec2 away = bent - centre;
    // The accent field (if any) is a tighter glow but stronger at its heart, so a bright colour
    // blooms clearly through the deep base and then falls away fast, instead of flooding
    bool isAccent = abs(f - accent) < 0.5;
    float weight = exp(-dot(away, away) * (isAccent ? 9.0 : 4.2)) * (isAccent ? 1.8 : 1.0) + 0.0005;
    // The first colour (the base) can be given a floor, so it always leads the mix
    if (i == 0) weight += lead;
    sum += colors[i] * colors[i] * weight;
    total += weight;
  }
  vec3 color = sqrt(sum / total);

  // A faint sheen along the folds, like light on a brushstroke
  color *= 1.0 + (r.x - 0.5) * 0.12 * warp;

  // Film grain, new every frame, plus a hair of dither so smooth ramps never band
  float speck = speckle(gl_FragCoord.xy + seed * 17.0) - 0.5;
  color += speck * (grain + 1.5 / 255.0);
  gl_FragColor = vec4(color, 1.0);
}`;

// ---------- small helpers ----------

function hexToRgb(hex: string): [number, number, number] {
  let value = hex.trim().replace(/^#/, "");
  if (value.length === 3) value = value.replace(/./g, (c) => c + c);
  const n = /^[0-9a-f]{6}$/i.test(value) ? parseInt(value, 16) : 0x808080;
  return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}

// Is the gradient light overall? Then text on it should be ink, otherwise paper
function isLight(colors: readonly string[]): boolean {
  const lightness = colors.map((hex) => {
    const [r, g, b] = hexToRgb(hex);
    return 0.2126 * r * r + 0.7152 * g * g + 0.0722 * b * b;
  });
  return lightness.reduce((a, b) => a + b, 0) / lightness.length > 0.3;
}

function pageIsDark(): boolean {
  const root = document.documentElement;
  const picked = root.getAttribute("data-theme");
  if (picked === "dark") return true;
  if (picked === "light") return false;
  if (root.classList.contains("dark")) return true;
  if (root.classList.contains("light")) return false;
  return window.matchMedia("(prefers-color-scheme: dark)").matches;
}

function watchPageTheme(onChange: () => void): () => void {
  const observer = new MutationObserver(onChange);
  observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "class"] });
  const query = window.matchMedia("(prefers-color-scheme: dark)");
  query.addEventListener("change", onChange);
  return () => {
    observer.disconnect();
    query.removeEventListener("change", onChange);
  };
}

function subscribeToMotion(onChange: () => void) {
  const query = window.matchMedia("(prefers-reduced-motion: reduce)");
  query.addEventListener("change", onChange);
  return () => query.removeEventListener("change", onChange);
}
const prefersReducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;

const noSubscription = () => () => {};

const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));

// ---------- the component ----------

export function MeshGradient({ colors = "auto", speed = 0.4, warp = 0.6, grain = 0.06, cursorInfluence = 0.5, theme = "auto", className = "", children }: MeshGradientProps) {
  const boxRef = useRef<HTMLDivElement>(null);
  const hostRef = useRef<HTMLDivElement>(null);
  const reducedMotion = useSyncExternalStore(subscribeToMotion, prefersReducedMotion, () => false);
  // The server can't know the page theme, so it renders no colours at all (see style below)
  const pageDark = useSyncExternalStore(watchPageTheme, pageIsDark, () => true);
  const hydrated = useSyncExternalStore(noSubscription, () => true, () => false);
  const dark = theme === "auto" ? pageDark : theme === "dark";

  // The colours actually used: a preset (in this theme's tuning) or the caller's own
  const presetName: NamedPreset | null = typeof colors === "string" ? (colors === "auto" ? "aurora" : colors) : null;
  const tuning = presetName ? presets[presetName][dark ? "dark" : "light"] : null;
  const palette = tuning ? tuning.colors : (colors as readonly string[]).slice(0, 5);
  const accent = tuning?.accent ?? -1;
  const paletteKey = palette.join(",");
  // Presets follow the theme (pale on light pages, deep on dark); your own colours are measured
  const light = presetName ? !dark : isLight(palette);

  // Dark presets let the deep base colour always lead, so light text never sits on a bright patch
  const lead = presetName && dark ? (presetName === "aurora" ? 0.35 : 0.45) : 0;
  const settings = useRef({ palette, speed, warp, grain, cursorInfluence, lead, accent });
  // In reduced motion there is no loop, so a prop change asks for one fresh still frame
  const redraw = useRef<(() => void) | null>(null);
  const [failed, setFailed] = useState(false);
  // A lost context (GPU reset, or the browser reclaiming contexts on a busy page) is retried
  const [lost, setLost] = useState(false);
  const retries = useRef(0);
  const [ready, setReady] = useState(false);

  // The render loop reads these every frame, so changing a prop never rebuilds WebGL
  useEffect(() => {
    settings.current = { palette: paletteKey ? paletteKey.split(",") : [], speed, warp, grain, cursorInfluence, lead, accent };
    redraw.current?.();
  }, [paletteKey, speed, warp, grain, cursorInfluence, lead, accent]);

  // After a lost context, wait a moment and build a fresh canvas (a few times at most, so a
  // GPU that keeps failing settles on the CSS fallback instead of flickering)
  useEffect(() => {
    if (!lost) return;
    const id = window.setTimeout(() => {
      retries.current += 1;
      setLost(false);
    }, 1200);
    return () => window.clearTimeout(id);
  }, [lost]);

  useEffect(() => {
    const box = boxRef.current;
    const host = hostRef.current;
    if (!box || !host || failed || lost) return;

    // A fresh canvas every time: a canvas whose context was released can't make a new one
    const canvas = document.createElement("canvas");
    canvas.setAttribute("aria-hidden", "true");
    canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%;display:block";
    host.appendChild(canvas);

    const gl = canvas.getContext("webgl", { antialias: false, alpha: false, depth: false, powerPreference: "low-power" });
    if (!gl) {
      canvas.remove();
      setFailed(true);
      return;
    }
    const context = gl;

    const program = context.createProgram()!;
    for (const [type, source] of [
      [context.VERTEX_SHADER, vertexShader],
      [context.FRAGMENT_SHADER, fragmentShader],
    ] as const) {
      const shader = context.createShader(type)!;
      context.shaderSource(shader, source);
      context.compileShader(shader);
      context.attachShader(program, shader);
      context.deleteShader(shader);
    }
    context.bindAttribLocation(program, 0, "position");
    context.linkProgram(program);
    if (!context.getProgramParameter(program, context.LINK_STATUS)) {
      context.deleteProgram(program);
      context.getExtension("WEBGL_lose_context")?.loseContext();
      canvas.remove();
      setFailed(true);
      return;
    }
    context.useProgram(program);

    // One triangle that covers the whole screen
    const buffer = context.createBuffer();
    context.bindBuffer(context.ARRAY_BUFFER, buffer);
    context.bufferData(context.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), context.STATIC_DRAW);
    context.enableVertexAttribArray(0);
    context.vertexAttribPointer(0, 2, context.FLOAT, false, 0, 0);

    const uniform = (name: string) => context.getUniformLocation(program, name);
    const at = {
      colors: uniform("colors"),
      count: uniform("count"),
      time: uniform("time"),
      aspect: uniform("aspect"),
      warp: uniform("warp"),
      grain: uniform("grain"),
      pointer: uniform("pointer"),
      pull: uniform("pull"),
      seed: uniform("seed"),
      lead: uniform("lead"),
      accent: uniform("accent"),
    };

    // The shown colours glide to new ones instead of jumping when the palette changes
    function targetColors() {
      const list = settings.current.palette.length ? settings.current.palette : presets.aurora.dark.colors;
      const rgb = list.map(hexToRgb);
      while (rgb.length < 5) rgb.push(rgb[rgb.length % list.length]!);
      return { values: rgb.flat(), count: clamp(list.length, 1, 5) };
    }
    const shown = targetColors().values;

    let alive = true;
    let frame = 0;
    let visible = true;
    // Start part-way into the loops so the first frame is already a nice composition
    let time = 14;
    let lastTime = performance.now();
    let seed = 0;

    // Where the colours lean: the eased pointer, and how present it is (0 when away)
    const pointer = { x: 0.5, y: 0.5, targetX: 0.5, targetY: 0.5, presence: 0, targetPresence: 0 };

    function resize() {
      // Soft colour needs few pixels: cap the pixel ratio at 1.5 to stay light on the GPU
      const pixelRatio = Math.min(window.devicePixelRatio || 1, 1.5);
      canvas.width = Math.max(1, Math.round(box!.clientWidth * pixelRatio));
      canvas.height = Math.max(1, Math.round(box!.clientHeight * pixelRatio));
      context.viewport(0, 0, canvas.width, canvas.height);
      if (reducedMotion) drawFrame();
    }

    function drawFrame() {
      const { count } = targetColors();
      const { warp: bend, grain: grainAmount, cursorInfluence: lean, lead, accent } = settings.current;
      context.uniform3fv(at.colors, shown);
      context.uniform1f(at.count, count);
      context.uniform1f(at.time, time);
      context.uniform1f(at.aspect, canvas.width / canvas.height);
      context.uniform1f(at.warp, clamp(bend, 0, 1));
      context.uniform1f(at.grain, clamp(grainAmount, 0, 0.2));
      context.uniform2f(at.pointer, pointer.x, pointer.y);
      context.uniform1f(at.pull, clamp(lean, 0, 1) * pointer.presence);
      context.uniform1f(at.seed, seed);
      context.uniform1f(at.lead, lead);
      context.uniform1f(at.accent, accent);
      context.drawArrays(context.TRIANGLES, 0, 3);
    }

    // Easing written per 60 Hz frame, turned into the right amount for any frame length
    const ease = (perFrame: number, frames: number) => 1 - Math.pow(1 - perFrame, frames);

    // Advance by the real time since the last frame (measured in 60 Hz frames), so the drift
    // is smooth whatever the refresh rate and never stalls or doubles up on a jittery frame
    function step(frames: number) {
      time += (frames / 60) * clamp(settings.current.speed, 0, 1) * 1.6;
      // The lean is slow on purpose: the colours drift towards the cursor, they don't chase it
      const follow = ease(0.035, frames);
      pointer.x += (pointer.targetX - pointer.x) * follow;
      pointer.y += (pointer.targetY - pointer.y) * follow;
      pointer.presence += (pointer.targetPresence - pointer.presence) * ease(0.03, frames);
      const glide = ease(0.05, frames);
      const target = targetColors().values;
      for (let i = 0; i < shown.length; i++) shown[i]! += (target[i]! - shown[i]!) * glide;
    }

    function render(now: number) {
      if (!alive) return;
      frame = requestAnimationFrame(render);
      if (!visible) {
        lastTime = now;
        return;
      }
      // Capped, so coming back to a tab doesn't jump the colours forward
      const frames = Math.min(Math.max(now - lastTime, 0), 100) / (1000 / 60);
      lastTime = now;
      step(frames);
      seed = (seed + 1.618) % 97;
      drawFrame();
    }

    function onPointerMove(event: PointerEvent) {
      if (!event.isPrimary) return;
      const rect = box!.getBoundingClientRect();
      pointer.targetX = (event.clientX - rect.left) / rect.width;
      pointer.targetY = 1 - (event.clientY - rect.top) / rect.height;
      pointer.targetPresence = 1;
    }
    function onPointerLeave(event: PointerEvent) {
      if (event.pointerType === "mouse") pointer.targetPresence = 0;
    }
    // A lifted finger lets the colours drift back by themselves
    function onPointerUp(event: PointerEvent) {
      if (event.pointerType !== "mouse") pointer.targetPresence = 0;
    }

    function onContextLost(event: Event) {
      event.preventDefault();
      cancelAnimationFrame(frame);
      if (!alive) return;
      if (retries.current < 3) setLost(true);
      else setFailed(true);
    }

    const resizeObserver = new ResizeObserver(resize);
    // Nothing to draw while the box is off screen
    const visibility = new IntersectionObserver(([entry]) => {
      visible = entry?.isIntersecting ?? true;
    });

    resize();
    resizeObserver.observe(box);
    canvas.addEventListener("webglcontextlost", onContextLost);

    if (reducedMotion) {
      // A still frame: the colours snap straight to the palette and stay put
      redraw.current = () => {
        shown.splice(0, shown.length, ...targetColors().values);
        drawFrame();
      };
      redraw.current();
    } else {
      visibility.observe(box);
      box.addEventListener("pointermove", onPointerMove);
      box.addEventListener("pointerdown", onPointerMove);
      box.addEventListener("pointerleave", onPointerLeave);
      box.addEventListener("pointerup", onPointerUp);
      box.addEventListener("pointercancel", onPointerUp);
      frame = requestAnimationFrame(render);
    }
    setReady(true);

    return () => {
      alive = false;
      redraw.current = null;
      cancelAnimationFrame(frame);
      resizeObserver.disconnect();
      visibility.disconnect();
      box.removeEventListener("pointermove", onPointerMove);
      box.removeEventListener("pointerdown", onPointerMove);
      box.removeEventListener("pointerleave", onPointerLeave);
      box.removeEventListener("pointerup", onPointerUp);
      box.removeEventListener("pointercancel", onPointerUp);
      canvas.removeEventListener("webglcontextlost", onContextLost);
      context.deleteBuffer(buffer);
      context.deleteProgram(program);
      // Hand the context back now: browsers only allow a few at a time
      context.getExtension("WEBGL_lose_context")?.loseContext();
      canvas.remove();
      setReady(false);
    };
  }, [reducedMotion, failed, lost]);

  // Under the canvas (and instead of it without WebGL): the same colours as soft CSS glows
  const [base = "#808080", ...rest] = palette;
  const spots = ["18% 22%", "82% 28%", "70% 82%", "24% 78%"];
  // The accent stays a small glow here too, as in the shader
  const fallback = rest
    .map((hex, i) => {
      const reach = i + 1 === accent ? "34% 34%" : "60% 60%";
      return `radial-gradient(${reach} at ${spots[i % spots.length]}, ${hex} 0%, transparent 70%)`;
    })
    .join(", ");

  // Until hydration the theme is unknown, so nothing commits to a tuning: no fallback colours
  // and no ink (text inherits the page colour). That avoids a flash of the dark palette on a
  // light page. The colours then ease in with the canvas.
  const style = (
    hydrated
      ? {
          "--mesh-ink": light ? "#191713" : "#f7f4ee",
          "--mesh-ink-soft": light ? "rgba(25, 23, 19, 0.68)" : "rgba(247, 244, 238, 0.78)",
          color: "var(--mesh-ink)",
          backgroundColor: base,
          backgroundImage: fallback,
          touchAction: "pan-y",
        }
      : { touchAction: "pan-y" }
  ) as CSSProperties;

  return (
    <div ref={boxRef} className={`relative overflow-hidden ${className}`} style={style}>
      {failed ? null : (
        <div
          ref={hostRef}
          aria-hidden="true"
          className="absolute inset-0"
          style={{ opacity: ready ? 1 : 0, transition: "opacity 1.6s cubic-bezier(0.2, 0.7, 0.2, 1)" }}
        />
      )}
      {/* Pointer events from here bubble up to the box, so the colours still follow the cursor under the content */}
      {children ? <div className="relative h-full w-full">{children}</div> : null}
    </div>
  );
}

The component. Installs to components/dimaac/mesh-gradient.tsx.

Install

With the shadcn CLI

npx shadcn@latest add https://ui.dimaac.com/r/mesh-gradient.json

It adds components/dimaac/mesh-gradient.tsx and installs what it imports. With the @dimaac namespace in components.json (Docs) it's just npx shadcn@latest add @dimaac/mesh-gradient.

By hand

# Nothing to install: it only needs React
  1. It needs nothing but React 19.
  2. Copy the component file below into components/dimaac/mesh-gradient.tsx.
  3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
  4. Import it with import { MeshGradient } from "@/components/dimaac/mesh-gradient" and give its parent a size: it fills it.

Props

PropTypeDefaultDescription
colorsreadonly string[] | MeshGradientPreset"auto"Four or five hex colours, or a preset name. "auto" is aurora, tuned for the page's light or dark theme.
speednumber0.4How fast the colours drift, from 0 (still) to 1.
warpnumber0.6How much the noise bends and swirls the colour fields, from 0 (soft blobs) to 1.
grainnumber0.06Film grain strength, from 0 to 0.2. It also hides colour banding.
cursorInfluencenumber0.5How much the colours lean towards the cursor (or finger), from 0 to 1.
theme"auto" | "light" | "dark""auto"Which tuning the presets use. "auto" follows the page: data-theme, a dark class, or the system setting.
classNamestring""Extra classes for the outer box. Give it a size (it fills it).
childrenReactNode-Content shown over the gradient. It can use var(--mesh-ink) for a text colour that reads on it.

Use with AI

Agent prompt

Paste it into Claude Code, Cursor or any coding agent, or open it straight in a chat. It carries your Customize settings.

Prompt
Add the Mesh Gradient component (<MeshGradient />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/mesh-gradient
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/mesh-gradient.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/mesh-gradient.json

Then use it like this:
import { MeshGradient } from "@/components/dimaac/mesh-gradient";
<MeshGradient />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/mesh-gradient.tsx.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt

Files agents read

llms.txt
Every component and how to install it
mesh-gradient.md
This page as Markdown: install, props and the full source
mesh-gradient.json
The shadcn registry item

About

A full-bleed animated gradient made of four or five soft colour fields. Each field drifts on a slow loop of its own, and two layers of noise bend the space they live in, so the edges fold and swirl like wet paint instead of sliding about as round blobs. Fine film grain sits on top and hides banding, and the colours lean slowly towards the cursor or a finger. It is one small WebGL fragment shader with no libraries, capped at a 1.5 pixel ratio, paused while off screen. Four presets come tuned for light and dark pages (aurora teal to mint, dusk, DiMaac lime and ink, and mono paper), or you pass your own colours. Content goes on top as children and can use var(--mesh-ink) for a text colour that reads on the gradient. When the visitor prefers reduced motion it shows a single still frame.

Installs to
components/dimaac/mesh-gradient.tsx
Uses
React only
Tags
background, gradient, webgl, shader, grain, hero