IrisBackgroundsOpal Wash
Opal Wash
Iridescent colour drifting across a milky ground, swirling under the cursor like wet paint.
Opal Wash
A hot red-orange anchored top-left, warm peach along the upper-right, a river of purple-magenta warping through the middle, periwinkle settling into near-white toward the bottom. Two layers of domain-warped noise pull the bands past each other like ink spreading in water — slow enough to read as one continuous drift rather than motion.
The cursor grips the flow around it and slowly swirls it; the touched region lifts toward white, as if a fingertip were drawn through wet paint. One of two light-ground fields here alongside `amber-glow` — its reading guard raises luminance for dark copy, the inverse of the others.
Install
No installation needed — self-contained, paste-in code.
Usage
Drop it straight into a page.
import { OpalField } from "./OpalField";
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">
<OpalField />
</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 wash of iridescent colour on a milky ground — a hot
* red-orange anchored top-left, a warm peach along the upper-right, a river
* of purple-magenta warping through the middle, and periwinkle settling into
* near-white toward the bottom. Everything flows: two layers of domain-warped
* noise pull the colour bands past each other like ink spreading in water,
* slow enough to read as one continuous drift rather than motion.
*
* The pointer is real. The cursor grips the flow around it and slowly swirls
* it — the bands nearest the cursor rotate and part, and the touched region
* lifts toward white, as if a fingertip were being drawn through wet paint.
* The grip eases in and out, so light that is leaving does not also jump.
*
* 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-opalfield__floor`
* underneath visible — a still composed frame in the same palette, never a
* blank box.
*
* Like `AuroraVeil` (and unlike `SilkField`), the palette is a fixed set
* passed as uniforms rather than read from the token ramp — these hues live
* nowhere in the token set, so the CSS floor carries the colour rather than a
* `var()`.
*
* Reading guard: when `guardSelector` resolves to an element, the field
* measures that block every frame and 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. This is the inverse of the dark fields
* (`TileField`, `SilkField`, `AuroraVeil`), whose guard clamps luminance
* *down* for light copy. `null` (the default) turns the guard off — for
* decorative use where nothing sits on top.
*/
/* Palette, sRGB 0–1. Uniforms, not tokens — see the note above. */
const PALETTE: Record<string, [number, number, number]> = {
u_white: [0.965, 0.965, 0.992], // the milky base — top edge and the lower wash
u_red: [0.937, 0.216, 0.129], // the hot red-orange, held in the top-left corner
u_coral: [0.996, 0.796, 0.741], // pale peach along the upper-right
u_magenta: [0.792, 0.435, 0.898], // soft orchid — the river through the middle
u_indigo: [0.62, 0.678, 0.949], // pale periwinkle, the lower field into white
};
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_red;
uniform vec3 u_coral;
uniform vec3 u_magenta;
uniform vec3 u_indigo;
/* Reading region for the contrast guard: xy = centre (uv, y up),
zw = half-extent. Measured from the live copy block every frame.
u_guard is 0 when the guard is off. */
uniform vec4 u_readA;
uniform float u_guard;
/* Five-stop ramp across t in 0..1: red → peach → orchid → periwinkle →
milky white. The overlaps are wide so no stop reads as a hard edge, and
every stop carries some white so the field stays diffuse rather than
poster-bright. */
vec3 ramp(float t) {
t = clamp(t, 0.0, 1.0);
vec3 c = mix(u_red, u_coral, smoothstep(0.00, 0.26, t));
c = mix(c, u_magenta, smoothstep(0.20, 0.52, t));
c = mix(c, u_indigo, smoothstep(0.48, 0.76, t));
c = mix(c, u_white, smoothstep(0.68, 1.00, t));
return c;
}
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: grip the flow and swirl 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 * 6.0);
/* rotate the sample point around the cursor — the bands nearest it turn */
float ang = grip * 1.7;
float s = sin(ang), co = cos(ang);
vec2 q = ptr + mat2(co, s, -s, co) * toP;
/* and part outward a touch, like paint pushed aside by a fingertip */
q += normalize(toP + 1e-4) * grip * 0.12;
/* ---- domain-warped flow field -------------------------------- */
/* Low spatial frequencies — the bands should read as broad soft masses
spreading like ink in water, not a fine turbulent texture. */
float t = u_time * 0.05;
vec2 w1 = vec2(fbm(q * 0.95 + vec2(0.0, t)),
fbm(q * 0.95 + vec2(5.2, -t)));
vec2 w2 = vec2(fbm(q * 1.9 + w1 * 1.7 + vec2(1.7, t * 1.2)),
fbm(q * 1.9 + w1 * 1.7 + vec2(-3.1, -t * 1.0)));
float flow = fbm(q * 1.15 + w2 * 2.0);
/* large-scale bias so the composition holds under the churn: the red is
pinned into the top-left corner (not a band across the whole top), the
field runs peach → orchid → periwinkle top-to-bottom, and settles into
milky white toward the base */
float vert = 1.0 - uv.y; /* 0 top .. 1 base */
float corner = 1.0 - smoothstep(0.0, 1.25, distance(uv, vec2(0.0, 1.0)));
float bias = vert * 0.92 - corner * 0.55 + 0.12;
float tval = clamp(bias + flow * 0.4, 0.0, 1.0);
vec3 col = ramp(tval);
/* an overall breath of white so nothing reads as poster-flat, plus a
milkier top edge as in the reference */
col = mix(col, u_white, 0.14);
col = mix(col, u_white, smoothstep(0.80, 1.04, uv.y) * 0.45);
/* ---- the touch: a soft luminous bloom under the cursor -------- */
col = mix(col, mix(col, u_white, 0.5), grip * 0.5);
col += u_coral * grip * 0.08;
col = max(col, 0.0);
/* ---- the reading guard --------------------------------------- */
/* Same measured region as the dark fields, opposite correction: this
ground is pale, so copy over it is dark, and the guard *raises*
luminance (washes toward white) instead of clamping it. */
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 OpalFieldProps {
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 OpalField({ className, guardSelector = null }: OpalFieldProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const guardOn = guardSelector != null;
/* The block the reading guard protects. Looked up once, lazily. */
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 SilkField / AuroraVeil: 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(_70%_58%_at_4%_0%,oklch(0.64_0.23_27)_0%,transparent_48%_),radial-gradient(_72%_60%_at_82%_12%,oklch(0.9_0.07_40)_0%,transparent_56%_),radial-gradient(_120%_78%_at_50%_46%,oklch(0.72_0.18_330)_0%,transparent_56%_),radial-gradient(_110%_96%_at_22%_96%,oklch(0.82_0.11_272)_0%,transparent_58%_),radial-gradient(_96%_90%_at_96%_108%,oklch(0.86_0.08_300)_0%,transparent_56%_),oklch(0.965_0.012_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