IrisBackgroundsCataract
Cataract
Five flat colour ribbons gathered to a point near the top, fanning wide as they fall — light travelling top to bottom, the pointer leaning the whole fall toward the cursor.
Cataract
Five flat ribbons — rust, orange, gold, teal, dark teal, left to right — gather to a single point high in the frame and spread from there as they fall, over a warm paper ground. None of them ever cross: the gathering point holds their left-to-right order, and each keeps it the whole way down, widening a little as it opens like a poured stream loosening once it lets go.
A bright pulse is born at the gathering point on every ribbon and falls the length of it, fading in as it starts and out again as it reaches the bottom — the waterfall's actual motion, each ribbon on its own phase so the frame never reads as one synchronised drop. The pointer leans the whole fan toward its x, the way a poured stream drifts if you nudge the vessel it's falling from, and whichever ribbon the cursor sits nearest flares bright at that height — a hand held into the fall.
Install
No installation needed — self-contained, paste-in code.
Usage
Drop it straight into a page.
import { CataractField } from "./CataractField";
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">
<CataractField />
</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 fan of five flat-coloured ribbons — rust, orange, gold, teal,
* dark teal, left to right — over a warm paper ground. All five gather to a
* single point high in the frame and spread from there as they fall,
* fanning wider the further down they run, the way a poured stream splits
* into separate threads the moment it lets go. None of the ribbons ever
* cross: the gathering point holds their left-to-right order, and every
* ribbon keeps it the whole way down.
*
* A bright pulse is born at the gathering point at the top of every ribbon
* and travels down its length, fading in as it starts and out again as it
* reaches the bottom edge — the waterfall's actual motion, light riding the
* fall rather than sitting still on top of it. Each ribbon runs its own
* phase, so the frame never reads as one synchronised drop.
*
* Interaction: the whole fan leans, gathering point and all, toward the
* cursor's x — the way a poured stream drifts if you nudge the vessel it is
* falling from. Whichever ribbon the cursor sits nearest also flares wide
* and bright at that height, a hand held into the fall.
*
* Like `ConfluenceField`, the ground here is light, not dark, and the
* palette is a fixed set of uniforms rather than the accent ramp — this is
* a direct build of a specific ribbon-waterfall reference, not a variation
* on the portfolio's own colours.
*
* 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-cataractfield__floor`
* underneath visible — a still frame in the same palette, never a blank box.
*
* Reading guard: when `guardSelector` resolves to an element, the field
* measures that block every frame and lifts its own colour toward the paper
* ground inside that region, the same move `ConfluenceField` makes for a
* light ground. `null` (the default) turns the guard off.
*/
/* Palette, sRGB 0–1. Uniforms, not tokens — a warm paper ground and five
flat ribbon colours have nowhere in the site's (dark-mode) ramp to live. */
const PALETTE: Record<string, [number, number, number]> = {
u_paper: [0.961, 0.941, 0.859],
u_rust: [0.83, 0.41, 0.28],
u_orange: [0.9, 0.63, 0.28],
u_gold: [0.9, 0.75, 0.29],
u_teal: [0.29, 0.61, 0.55],
u_deep: [0.15, 0.27, 0.33],
};
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_paper;
uniform vec3 u_rust;
uniform vec3 u_orange;
uniform vec3 u_gold;
uniform vec3 u_teal;
uniform vec3 u_deep;
uniform vec4 u_readA;
uniform float u_guard;
/* Where the ribbons gather, in the fan's own x (0..1, before the sway). */
const float GATHER_X = 0.5;
const float GATHER_Y = 0.86;
/* One ribbon: pinched to GATHER_X/Y above gatherTop, then easing out to its
own bottomX by y = 0 — a fan opening as it falls, not a bend at a fixed
hinge. Thickness shrinks toward the gathering point, the way a poured
stream narrows just before it leaves the lip it's pouring from. */
float ribbon(
inout vec3 col,
vec2 uv,
float time,
float bottomX,
float thickBottom,
float gatherTop,
float endY,
vec3 tint,
float phase,
vec3 paper,
vec3 pointer,
float fanSway
) {
float gx = GATHER_X + fanSway;
float t = clamp(uv.y / gatherTop, 0.0, 1.0); /* 0 at y=0, 1 at gatherTop */
float k = t * t; /* pinches late, near the top */
float x = mix(bottomX + fanSway, gx, k);
float thickness = mix(thickBottom, thickBottom * 0.22, t);
float halfW = thickness * 0.5;
float edge = thickness * 0.16;
float d = abs(uv.x - x);
float body = 1.0 - smoothstep(halfW - edge, halfW, d);
/* a faint gloss across the cross-section */
float gloss = 1.0 - 0.16 * clamp((uv.x - x) / halfW, -1.0, 1.0);
/* visible below the gathering point, gone above it; alive down to its own
staggered end, gone past that */
float born = 1.0 - smoothstep(gatherTop - 0.02, gatherTop + 0.03, uv.y);
float alive = smoothstep(endY - 0.015, endY + 0.015, uv.y);
float cov = body * born * alive;
vec3 shaded = tint * gloss;
/* the waterfall's motion: a pulse born at the gathering point, falling to
this ribbon's own end */
float cycle = fract(time * 0.16 + phase);
float travelY = mix(gatherTop, endY, cycle);
float distY = uv.y - travelY;
float pulse = exp(-distY * distY * 260.0);
pulse *= smoothstep(0.0, 0.06, cycle) * (1.0 - smoothstep(0.88, 1.0, cycle));
shaded = mix(shaded, paper, 0.78 * clamp(pulse, 0.0, 1.0));
/* the cursor as a hand held into the fall: check whether it is truly
sitting on THIS ribbon at its own height (mirroring the geometry above,
evaluated at pointer.y) rather than just near this ribbon's body at the
current pixel's height — otherwise every ribbon would flare together
whenever the cursor crossed their shared height. */
float tp = clamp(pointer.y / gatherTop, 0.0, 1.0);
float xp = mix(bottomX + fanSway, gx, tp * tp);
float halfWp = mix(thickBottom, thickBottom * 0.22, tp) * 0.5;
float alignX = exp(-pow((pointer.x - xp) / (halfWp * 0.9 + 0.015), 2.0));
float hit = pointer.z * alignX;
float atHeight = exp(-pow(uv.y - pointer.y, 2.0) / 0.006);
float flare = hit * body * atHeight;
shaded = mix(shaded, paper, 0.5 * flare);
shaded += vec3(flare * 0.4);
col = mix(col, shaded, clamp(cov, 0.0, 1.0));
return cov;
}
void main() {
vec2 res = u_res / u_scale;
vec2 uv = gl_FragCoord.xy / u_scale / res; /* 0..1, y up, x left..right */
vec2 sc = vec2(res.x / max(res.y, 1.0), 1.0);
/* the whole fan leans toward the cursor's x, like the vessel it pours
from being nudged; a slow idle sway when nobody is pointing */
float idleSway = 0.02 * sin(u_time * 0.05);
float fanSway = mix(idleSway, (u_pointer.x - GATHER_X) * 0.35, u_pointer.z);
vec3 col = u_paper;
ribbon(col, uv, u_time, 0.095, 0.155, GATHER_Y, 0.045, u_rust, 0.05, u_paper, u_pointer, fanSway);
ribbon(col, uv, u_time, 0.315, 0.145, GATHER_Y, 0.015, u_orange, 0.34, u_paper, u_pointer, fanSway);
ribbon(col, uv, u_time, 0.515, 0.085, GATHER_Y, 0.055, u_gold, 0.61, u_paper, u_pointer, fanSway);
ribbon(col, uv, u_time, 0.700, 0.145, GATHER_Y, 0.03, u_teal, 0.83, u_paper, u_pointer, fanSway);
ribbon(col, uv, u_time, 0.930, 0.175, GATHER_Y, 0.0, u_deep, 0.19, u_paper, u_pointer, fanSway);
/* the source glow — where every ribbon gathers, a soft bloom of light
spilling out before it splits */
vec2 gp = vec2(GATHER_X + fanSway, GATHER_Y) * sc;
vec2 toGather = uv * sc - gp;
float sourceGlow = exp(-dot(toGather, toGather) / (0.02 * 0.02));
col = mix(col, u_paper, sourceGlow * 0.9);
col += vec3(sourceGlow * 0.5);
/* faint paper grain — a flat, near-white ground bands visibly across an
eight-bit buffer without it */
col += (bayer8(gl_FragCoord.xy) - 0.5) * (1.6 / 255.0);
/* ---- the reading guard: lift toward paper, not down toward black ---- */
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 guardBand = 1.0 - smoothstep(0.72, 2.1, m);
col = mix(col, u_paper, guardBand * u_guard * 0.85);
gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
}
`;
export interface CataractFieldProps {
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 CataractField({
className,
guardSelector = null,
}: CataractFieldProps) {
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 ConfluenceField / CascadeField: 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: 1_800_000,
dprCap: 1.5,
alpha: false,
});
}, [guardSelector]);
return (
<div
className={`absolute inset-0 overflow-hidden${className ? ` ${className}` : ""}`}
aria-hidden="true"
>
<div className="absolute inset-0 [background:conic-gradient(_from_200deg_at_50%_3%,oklch(0.61_0.15_35)_0deg,oklch(0.61_0.15_35)_28deg,oklch(0.74_0.15_60)_28deg,oklch(0.74_0.15_60)_46deg,oklch(0.81_0.14_85)_46deg,oklch(0.81_0.14_85)_58deg,oklch(0.62_0.1_180)_58deg,oklch(0.62_0.1_180)_76deg,oklch(0.35_0.06_220)_76deg,oklch(0.35_0.06_220)_108deg_),oklch(0.96_0.02_90)] [background-blend-mode:normal]" />
<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