Skip to content

IrisBackgroundsOpal Wash

Opal Wash

Iridescent colour drifting across a milky ground, swirling under the cursor like wet paint.

wash

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.

  • iridescent
  • flowing
Family
wash
Status
Available
Licence
Free

Install

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

Usage

Drop it straight into a page.

example.tsx
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>
  );
}

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