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 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.
Install
No installation needed — self-contained, paste-in code.
Usage
Drop it straight into a page.
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>
);
}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
Reads and answers every request himself