IrisBackgroundsConfluence
Confluence
Eight flat colour ribbons fanning wide on the left, cascading into tight parallel bands running right — a strip of light travelling the length of each.
Confluence
A cool trio — dark teal, teal, mint — sits above a warm quintet — gold, orange, rust, red-orange, dark red — over a paper-white ground. At the left edge all eight fan out near-vertical, spread across almost the full height; moving right, each eases into a flat, tightly packed horizontal band, the ones nearer the top settling first and the ones nearer the bottom a beat later, a soft cascade rather than one shared hinge. None of the bands ever cross, and each runs to its own length — some stopping short, a couple carrying to the very edge — so the right side reads like a staggered bar chart rather than a squared-off wall.
A bright pulse is born at the left edge of every ribbon and travels right along it, fading in as it starts and fading out again exactly as it reaches that ribbon's own end — the light animating the length of the piece rather than sitting still on top of it. Each ribbon runs its own phase, so the frame never reads as one synchronised sweep. Unlike the rest of the catalogue, the ground here is light and there is no pointer interaction — a steady, automatic flow instead.
Install
No installation needed — self-contained, paste-in code.
Usage
Drop it straight into a page.
import { ConfluenceField } from "./ConfluenceField";
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">
<ConfluenceField />
</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 field of eight flat-coloured ribbons on a paper-white ground:
* a cool teal trio (dark teal, teal, mint) above a warm gold-to-red quintet
* (gold, orange, rust, red-orange, dark red), each fanned wide near-vertical
* on the left edge and easing — earlier for the ribbons nearer the top,
* later for the ones nearer the bottom, a soft cascade rather than one
* shared hinge — into flat, tightly packed horizontal bands that run the
* rest of the way across. The bands stay in the same top-to-bottom order
* throughout, so none of them ever cross, and each ends its own length
* short of or right at the frame's edge, staggered like a bar chart rather
* than squared off.
*
* A bright pulse is born at the left edge of every ribbon and travels right
* along it, fading in as it starts and fading out again exactly as it
* reaches that ribbon's own end — the strip of light animating from left to
* right, to the end. Each ribbon's pulse runs on its own phase, so the
* frame never reads as one synchronised sweep.
*
* Unlike the rest of this catalogue, the ground here is light, not dark —
* this field is a direct build of a specific warm/cool ribbon-flow
* reference rather than a variation on the dark ambient palette the other
* fields share. There is no pointer interaction: the composition is a
* steady, automatic flow, not something a visitor's cursor reaches into.
*
* 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-confluencefield__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 opposite move from the dark
* fields' `holdUnder` luminance ceiling, because a light ground needs more
* white under a caption, not less light. `null` (the default) turns the
* guard off — for decorative use where nothing sits on top.
*/
/* Palette, sRGB 0–1. Uniforms, not tokens — a light paper ground and eight
flat ribbon colours have nowhere in the site's (dark-mode) ramp to live. */
const PALETTE: Record<string, [number, number, number]> = {
u_paper: [0.98, 0.978, 0.972],
u_teal0: [0.09, 0.32, 0.4], // dark teal, topmost
u_teal1: [0.14, 0.52, 0.58], // teal
u_teal2: [0.62, 0.83, 0.76], // mint
u_warm0: [0.89, 0.62, 0.19], // gold
u_warm1: [0.84, 0.46, 0.17], // orange
u_warm2: [0.73, 0.34, 0.14], // rust
u_warm3: [0.68, 0.23, 0.12], // red-orange
u_warm4: [0.58, 0.14, 0.1], // dark red, bottommost
};
const FRAG = `
uniform vec2 u_res;
uniform float u_time;
uniform float u_scale;
uniform vec3 u_paper;
uniform vec3 u_teal0;
uniform vec3 u_teal1;
uniform vec3 u_teal2;
uniform vec3 u_warm0;
uniform vec3 u_warm1;
uniform vec3 u_warm2;
uniform vec3 u_warm3;
uniform vec3 u_warm4;
uniform vec4 u_readA;
uniform float u_guard;
/* One ribbon: near-vertical at the left edge, easing into a flat band by
bendX, then holding that band out to endX (past 1.0 for "runs off the
frame"). yLeft/yRight and thickLeft/thickRight are eased together, so the
ribbon thins as it straightens exactly the way a bundle does when it
stops fanning and starts running parallel. */
void ribbon(
inout vec3 col,
vec2 uv,
float time,
float yLeft,
float yRight,
float thickLeft,
float thickRight,
float bendX,
float endX,
vec3 tint,
float phase,
vec3 paper
) {
float t = clamp(uv.x / bendX, 0.0, 1.0);
float eased = t * (2.0 - t); /* steep at x=0, flat by bendX */
float y = mix(yLeft, yRight, eased);
float thickness = mix(thickLeft, thickRight, eased);
float halfW = thickness * 0.5;
float edge = thickness * 0.12;
float d = abs(uv.y - y);
float body = 1.0 - smoothstep(halfW - edge, halfW, d);
/* a faint gloss across the cross-section, brighter toward the upper
edge — the soft sheen the reference photo carries on each band */
float gloss = 1.0 - 0.16 * clamp((uv.y - y) / halfW, -1.0, 1.0);
/* this ribbon's own staggered terminus, soft-cut like the sides */
float alive = 1.0 - smoothstep(endX - 0.015, endX + 0.015, uv.x);
float cov = body * alive;
if (cov <= 0.001) return;
vec3 shaded = tint * gloss;
/* the travelling light: born at x=0, sliding right, fading out again as
it reaches this ribbon's own end */
float cycle = fract(time * 0.14 + phase);
float travel = cycle * min(endX, 1.0);
float distX = uv.x - travel;
float pulse = exp(-distX * distX * 220.0);
pulse *= smoothstep(0.0, 0.06, cycle) * (1.0 - smoothstep(0.88, 1.0, cycle));
shaded = mix(shaded, paper, 0.82 * clamp(pulse, 0.0, 1.0));
col = mix(col, shaded, cov);
}
void main() {
vec2 res = u_res / u_scale;
vec2 uv = gl_FragCoord.xy / u_scale / res; /* 0..1, y up, x left..right */
vec3 col = u_paper;
/* cool trio — top to bottom */
ribbon(col, uv, u_time, 0.9175, 0.662, 0.105, 0.032, 0.30, 0.87, u_teal0, 0.02, u_paper);
ribbon(col, uv, u_time, 0.7945, 0.623, 0.105, 0.032, 0.34, 1.05, u_teal1, 0.35, u_paper);
ribbon(col, uv, u_time, 0.6715, 0.584, 0.105, 0.032, 0.37, 0.92, u_teal2, 0.61, u_paper);
/* warm quintet — top to bottom */
ribbon(col, uv, u_time, 0.5215, 0.534, 0.105, 0.032, 0.40, 1.05, u_warm0, 0.83, u_paper);
ribbon(col, uv, u_time, 0.3985, 0.495, 0.105, 0.032, 0.43, 0.95, u_warm1, 0.18, u_paper);
ribbon(col, uv, u_time, 0.2755, 0.456, 0.105, 0.032, 0.46, 0.88, u_warm2, 0.47, u_paper);
ribbon(col, uv, u_time, 0.1525, 0.417, 0.105, 0.032, 0.49, 0.99, u_warm3, 0.72, u_paper);
ribbon(col, uv, u_time, 0.0295, 0.378, 0.105, 0.032, 0.52, 0.83, u_warm4, 0.94, u_paper);
/* 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 ConfluenceFieldProps {
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 ConfluenceField({
className,
guardSelector = null,
}: ConfluenceFieldProps) {
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 SlipstreamField / CoronaField: 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:linear-gradient(_to_bottom,oklch(0.42_0.07_205)_0%,oklch(0.42_0.07_205)_11%,oklch(0.58_0.09_195)_11%,oklch(0.58_0.09_195)_22%,oklch(0.86_0.06_165)_22%,oklch(0.86_0.06_165)_33%,oklch(0.98_0.005_90)_33%,oklch(0.98_0.005_90)_41%,oklch(0.78_0.14_75)_41%,oklch(0.78_0.14_75)_52%,oklch(0.68_0.16_55)_52%,oklch(0.68_0.16_55)_63%,oklch(0.58_0.17_42)_63%,oklch(0.58_0.17_42)_74%,oklch(0.5_0.18_32)_74%,oklch(0.5_0.18_32)_85%,oklch(0.42_0.16_27)_85%,oklch(0.42_0.16_27)_100%_)]" />
<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