Skip to content

IrisBackgroundsMesh Fluid

Mesh Fluid

A mesh gradient of lavender, sky blue and rose melting into each other, with one wandering thread of light and a pointer that stirs it.

mesh

Mesh Fluid

Five soft colour anchors — lavender, sky blue, rose, pale peach, deep indigo — blend by distance rather than banding, so the field reads as blobs of colour melting into each other over a milky ground, each one drifting on its own slow orbit. A gentle domain warp keeps every edge organic rather than a perfect radial gradient, and one thread of near-white light wanders through the mesh, its curve bent by the same warp the colour rides rather than laid over it.

The cursor reaches into the fluid rather than just lighting it: the mesh nearest the pointer visibly swirls around it, and the touched spot lifts toward white, the way a fingertip drawn through wet paint would. Move away and the swirl eases back into the mesh's own slow drift.

  • pastel
  • pointer-driven
  • mesh-gradient
Family
mesh
Status
Available
Licence
Free

Install

No installation needed — self-contained, paste-in code.

Usage

Drop it straight into a page.

example.tsx
import { MeshFluidField } from "./MeshFluidField";

export default function Example() {
  return (
    // Fills its nearest positioned ancestor (it renders itself `absolute
    // inset-0`) — give it a sized, relatively positioned box.
    <div className="relative isolate h-[32rem] w-full overflow-hidden rounded-2xl">
      <MeshFluidField />
    </div>
  );
}

Component

The real source, exactly as it ships — multiple files, kept together.

"use client";

import { useEffect, useRef } from "react";
import { mountShaderSurface } from "@/lib/shader-surface";

/**
 * A live WebGL mesh gradient — five soft colour anchors (lavender, sky blue,
 * rose, pale peach, deep indigo) blended by inverse-distance weight rather
 * than banded, so the field reads as blobs of colour melting into each
 * other, not a ramp. A slow domain warp keeps every blob's edge organic
 * rather than a perfect radial gradient, and one thread of near-white light
 * wanders through the mesh, its curve riding the same warped coordinate the
 * colour does — bent by the fluid, not laid over it.
 *
 * The pointer reaches into the fluid: it swirls the sample point around
 * itself (the mesh nearest the cursor visibly turns) and stands in as one
 * more anchor, near-white, so the touched spot lifts toward light the way a
 * fingertip drawn through wet paint would.
 *
 * Drop it into any `position: relative`/`isolate` parent — it fills the box.
 * Built on `lib/shader-surface.ts`, so every degradation path is already
 * handled: no WebGL, a blocked or lost context, a hidden tab, or
 * `prefers-reduced-motion` all leave the CSS `.iris-meshfluidfield__floor`
 * underneath visible — a still composition in the same palette, never a
 * blank box.
 *
 * Like `OpalField`, the palette is a fixed set of uniforms rather than read
 * from the token ramp — these pastels live nowhere in the site's tokens, so
 * the CSS floor carries the colour rather than a `var()`.
 *
 * Reading guard: when `guardSelector` resolves to an element, the field
 * washes its own colour toward white inside that region (hue kept,
 * luminance raised) so dark copy laid over it holds WCAG AA with no overlay
 * layer — the ground is pale, same direction as `OpalField`'s guard. `null`
 * (the default) turns the guard off.
 */

/* Palette, sRGB 0–1. Uniforms, not tokens — see the note above. */
const PALETTE: Record<string, [number, number, number]> = {
  u_white: [0.976, 0.972, 0.99], // the milky overall wash, and the thread of light
  u_lilac: [0.702, 0.612, 0.886], // the main lavender mass
  u_blue: [0.545, 0.706, 0.937], // the sky-blue swirl through the middle
  u_pink: [0.945, 0.729, 0.847], // the rose blob
  u_peach: [0.996, 0.894, 0.796], // the pale cream corner wash
  u_violet: [0.376, 0.412, 0.788], // the deeper indigo, for contrast
};

const FRAG = `
uniform vec2  u_res;
uniform float u_time;
uniform float u_scale;
uniform vec3  u_pointer;  /* x, y (0 bottom .. 1 top), presence 0..1 */

uniform vec3 u_white;
uniform vec3 u_lilac;
uniform vec3 u_blue;
uniform vec3 u_pink;
uniform vec3 u_peach;
uniform vec3 u_violet;

uniform vec4  u_readA;
uniform float u_guard;

/* One anchor's pull on a sample point: inverse-distance weighting raised to
   a power, so anchors blend as soft, melting blobs rather than a linear
   ramp — a lower power reads as a broad ambient wash, a higher one as a
   more defined mass. */
float pull(vec2 p, vec2 a, float power) {
  return 1.0 / pow(length(p - a) + 0.05, power);
}

void main() {
  vec2 res = u_res / u_scale;
  vec2 uv  = gl_FragCoord.xy / u_scale / res;          /* 0..1, y up */
  float aspect = res.x / res.y;
  vec2 P = (uv - 0.5) * vec2(aspect, 1.0);

  /* ---- the pointer: swirl the fluid around it -------------------- */
  vec2 ptr = (u_pointer.xy - 0.5) * vec2(aspect, 1.0);
  vec2 toP = P - ptr;
  float dP = length(toP);
  float grip = u_pointer.z * exp(-dP * dP * 7.0);

  float ang = grip * 1.5;
  float s = sin(ang), co = cos(ang);
  vec2 q = ptr + mat2(co, s, -s, co) * toP;

  float t = u_time * 0.045;

  /* a slow domain warp so every blob's edge breathes organically instead
     of sitting as a perfect radial gradient */
  vec2 w1 = vec2(fbm(q * 0.8 + vec2(0.0, t)), fbm(q * 0.8 + vec2(4.1, -t)));
  q += w1 * 0.14;

  /* anchor positions, each drifting slowly on its own small orbit so the
     mesh never holds perfectly still */
  vec2 uvA = vec2(0.28, 0.45) + 0.07 * vec2(cos(t * 0.9 + 0.0), sin(t * 0.7 + 0.6));
  vec2 uvB = vec2(0.62, 0.32) + 0.09 * vec2(cos(t * 0.6 + 2.1), sin(t * 0.8 + 1.1));
  vec2 uvC = vec2(0.86, 0.82) + 0.06 * vec2(cos(t * 0.75 + 4.2), sin(t * 0.55 + 3.0));
  vec2 uvD = vec2(0.94, 0.12) + 0.05 * vec2(cos(t * 0.5 + 1.4), sin(t * 0.65 + 2.4));
  vec2 uvE = vec2(0.10, 0.92) + 0.05 * vec2(cos(t * 0.65 + 3.3), sin(t * 0.5 + 0.8));

  vec2 aA = (uvA - 0.5) * vec2(aspect, 1.0);
  vec2 aB = (uvB - 0.5) * vec2(aspect, 1.0);
  vec2 aC = (uvC - 0.5) * vec2(aspect, 1.0);
  vec2 aD = (uvD - 0.5) * vec2(aspect, 1.0);
  vec2 aE = (uvE - 0.5) * vec2(aspect, 1.0);

  float wA = pull(q, aA, 2.2);  /* lilac  — the main lavender mass */
  float wB = pull(q, aB, 2.0);  /* blue   — the swirl through the middle */
  float wC = pull(q, aC, 2.4);  /* pink   — the rose blob */
  float wD = pull(q, aD, 1.2);  /* peach  — the pale corner wash */
  float wE = pull(q, aE, 1.3);  /* violet — the deeper corner, for contrast */
  float wP = grip * 34.0;       /* the touch itself, lifting toward white */

  float wSum = wA + wB + wC + wD + wE + wP + 1e-4;
  vec3 col = (u_lilac * wA + u_blue * wB + u_pink * wC + u_peach * wD +
              u_violet * wE + u_white * wP) / wSum;

  /* a light milkiness, and a paler wash along the very top edge, so the
     field never reads as poster-flat colour without washing the mesh out */
  col = mix(col, u_white, 0.05);
  col = mix(col, u_white, smoothstep(0.82, 1.06, uv.y) * 0.12);

  /* ---- the one thread of light drawn through the fluid ------------ */
  /* Its curve rides the same warped, swirled coordinate the colour does,
     so it bends with the mesh rather than sitting on top of it as a
     separate overlay — a slow wander plus a tighter wobble riding on it.
     Tight falloff constants keep it a hairline, not a wash: the colour
     either side is meant to stay legible, not get blown out by the glow. */
  float phase = q.x * 2.1 + t * 1.4;
  float curveY = 0.16 * sin(phase) + 0.06 * sin(phase * 2.3 + 1.7) - 0.10 * q.x;
  float d = q.y - curveY;
  float halo = exp(-d * d * 90.0);
  float core = exp(-d * d * 520.0);
  col = mix(col, u_white, halo * 0.16);
  col = mix(col, u_white, core * 0.8);

  /* the touch brightens whatever thread or blob is nearest it */
  col += u_white * grip * 0.1;

  col = max(col, 0.0);

  /* ---- the reading guard (pale ground: raise luminance, keep hue) -- */
  vec2 rd = abs(uv - u_readA.xy) / max(u_readA.zw, vec2(0.02));
  float m = mix(max(rd.x, rd.y), length(rd), 0.4);
  float band = 1.0 - smoothstep(0.72, 2.1, m);
  col = mix(col, mix(col, u_white, 0.72), band * u_guard);

  col += (bayer8(gl_FragCoord.xy) - 0.5) * (2.2 / 255.0);

  gl_FragColor = vec4(col, 1.0);
}
`;

export interface MeshFluidFieldProps {
  className?: string;
  /**
   * CSS selector for the block the reading guard should keep readable,
   * resolved against `document`. `null` (the default) turns the guard off.
   */
  guardSelector?: string | null;
}

export function MeshFluidField({ className, guardSelector = null }: MeshFluidFieldProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const guardOn = guardSelector != null;
    let guardEl: Element | null | undefined;
    const readGuardEl = () => {
      if (guardEl === undefined) {
        guardEl = guardOn ? document.querySelector(guardSelector as string) : null;
      }
      return guardEl;
    };

    return mountShaderSurface(canvas, {
      fragment: FRAG,
      uniforms: [...Object.keys(PALETTE), "u_readA", "u_guard"],
      onInit: (gl, u) => {
        for (const name of Object.keys(PALETTE)) {
          if (u[name]) gl.uniform3fv(u[name], PALETTE[name]);
        }
        if (u.u_readA) gl.uniform4f(u.u_readA, 0.5, 0.5, 0.44, 0.32);
        if (u.u_guard) gl.uniform1f(u.u_guard, guardOn ? 1 : 0);
      },
      onFrame: (gl, u, s) => {
        if (!guardOn || !u.u_readA) return;
        let cx = 0.5, cy = 0.5, hw = 0.44, hh = 0.32;
        const el = readGuardEl();
        const { rect } = s;
        if (el && rect.width > 0 && rect.height > 0) {
          const r = el.getBoundingClientRect();
          const padX = rect.width * 0.09;
          const padY = rect.height * 0.11;
          cx = (r.left + r.width / 2 - rect.left) / rect.width;
          cy = 1 - (r.top + r.height / 2 - rect.top) / rect.height;
          hw = (r.width / 2 + padX) / rect.width;
          hh = (r.height / 2 + padY) / rect.height;
        }
        gl.uniform4f(u.u_readA, cx, cy, hw, hh);
      },
      onPainted: () => canvas.setAttribute("data-shader", "on"),
      /* No onIdle — same contract as OpalField / SilkField: once painted,
         the last frame stays on screen while the surface is parked
         off-view, so scrolling away and back does not crossfade to the
         static floor. A lost context still clears it below. */
      onLost: () => canvas.removeAttribute("data-shader"),
      maxPixels: 2_200_000,
      dprCap: 1.5,
    });
  }, [guardSelector]);

  return (
    <div
      className={`absolute inset-0 overflow-hidden${className ? ` ${className}` : ""}`}
      aria-hidden="true"
    >
      <div className="absolute inset-0 [background:radial-gradient(_65%_60%_at_28%_45%,oklch(0.68_0.14_300)_0%,transparent_62%_),radial-gradient(_70%_65%_at_62%_32%,oklch(0.78_0.1_250)_0%,transparent_60%_),radial-gradient(_55%_50%_at_86%_82%,oklch(0.82_0.09_350)_0%,transparent_60%_),radial-gradient(_70%_60%_at_94%_12%,oklch(0.93_0.05_75)_0%,transparent_62%_),radial-gradient(_60%_55%_at_10%_92%,oklch(0.55_0.13_280)_0%,transparent_58%_),oklch(0.94_0.02_300)]" />
      <canvas ref={canvasRef} className="absolute inset-0 w-full h-full opacity-0 transition-opacity duration-[--duration-slow] ease-[--ease-standard] data-[shader=on]:opacity-100" />
    </div>
  );
}

More backgrounds

Custom work

Need one that isn't in the catalogue?

Describe what you're after and I'll reply by email — no promise on turnaround yet, this is a new channel, not a service with a set price or queue.

Fardin Omor Afnan

Fardin Omor Afnan

Reads and answers every request himself

Or start with a section.

Whole page sections, composed and ready to drop in — take one and build the rest of the page around it.

Browse sections