IrisBackgroundsArc Lights
Arc Lights
Three lights riding an invisible arc, their colours merging where they overlap.
Arc Lights
Three lights are each constrained to a position along a half-ellipse — height is a consequence of where they are, not a second animation to keep in sync. Each runs a seeded random walk, so a reduced-motion visitor gets a real composed frame rather than a frozen effect.
Where two lights overlap, the shader takes the energy-weighted mean of their colours: the bridge between them is a genuinely new colour. The pointer joins that same merge as a fourth light rather than sitting over it as a glow.
Install
No installation needed — self-contained, paste-in code.
Usage
Drop it straight into a page.
import { ArcLightsField } from "./ArcLightsField";
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">
<ArcLightsField />
</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 three lights riding an invisible half-ellipse arc —
* the standalone version of the geometry `components/contact/ContactField`
* built for the page's closing section, generalised into a drop-in
* background and given a real pointer.
*
* Each light owns a position ALONG the arc and nothing else — height is a
* consequence of where it is, not a second animation kept in sync. The
* travel is a seeded random walk simulated on the CPU (a target speed
* re-drawn at random intervals, eased into, reflected at both ends of the
* arc so a turnaround decelerates through zero), which is why a
* reduced-motion visitor still gets a real composed frame rather than a
* frozen effect: the still draw replays the same simulation from zero.
*
* Where lights overlap, the shader takes the ENERGY-WEIGHTED MEAN of their
* colours rather than crossfading — the bridge between two lights is a
* genuinely new hue, formed and dissolved out of the same numbers that move
* them. See ContactField's doc comment for the full reasoning.
*
* The pointer is a real member of that same merge, not an overlay: a fourth
* light follows the cursor and wins its own share of the energy-weighted
* mean, so a visitor's touch mixes into whichever colour it lands near. The
* three real lights also lean toward it, the same way SilkField's folds or
* EmberField's sparks bend toward a passing hand.
*
* 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-arclightsfield__floor`
* underneath visible.
*
* Palette: the same three tokens `ContactField` uses (aliases of the
* trajectory section's role colours, in app/globals.css), so the standalone
* piece and the site's own closing field are lit by the same three lights.
*
* Reading guard: when `guardSelector` resolves to an element, the field
* measures that block every frame and clamps its own luminance under a
* ceiling inside that region (hue and saturation untouched). `null` (the
* default) turns the guard off — for decorative use where nothing sits on
* top.
*/
/* The arc, in the canvas's own uv (y up). Same shape ContactField uses —
feet past the horizontal edges, so it reads as a rising curve rather than
a shape sitting on the floor. */
const ARC_HALF = 0.58;
const ARC_BASE = 0.02;
const ARC_RISE = 0.46;
/* One overlapping sector per light rather than one range shared by all
three — see ContactField for why: three independent walks over the whole
arc spend most of their time bunched at one end. */
const SECTORS: ReadonlyArray<readonly [number, number]> = [
[0.06, 0.44],
[0.28, 0.72],
[0.56, 0.94],
];
const arcY = (x: number) => {
const k = (x - 0.5) / ARC_HALF;
return ARC_BASE + ARC_RISE * Math.sqrt(Math.max(0, 1 - k * k));
};
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 */
/* Per real light: xy = position in uv, z = radius in height units, w =
strength. Simulated on the CPU — the walk is stateful and random, which
is not a thing a fragment shader can carry between frames. */
uniform vec4 u_light[3];
uniform vec3 u_ground;
uniform vec3 u_c1;
uniform vec3 u_c2;
uniform vec3 u_c3;
uniform vec3 u_touch; /* the pointer's own light, always this colour, so a
visitor can tell their touch from the three that
were already there */
uniform vec4 u_readA;
uniform float u_guard;
/* One light's contribution at a point — a gaussian rather than a hard edge,
because the SUM of gaussians is what fuses two nearby lights into one
body with no test for whether they are "touching". */
float lightAt(vec4 L, vec2 q, vec2 sc) {
vec2 d = q - L.xy * sc;
return exp(-dot(d, d) / max(L.z * L.z, 1e-4)) * L.w;
}
void main() {
vec2 res = u_res / u_scale;
vec2 uv = gl_FragCoord.xy / u_scale / res; /* 0..1, y up */
vec2 sc = vec2(res.x / max(res.y, 1.0), 1.0); /* aspect, in height units */
vec2 q = uv * sc;
/* the touch bends the space itself: nearby real lights lean toward the
cursor, the way SilkField's folds or EmberField's sparks bend toward a
passing hand. The touch's own light below is drawn at the unwarped
point, so it always sits exactly under the cursor. */
vec2 ptr = u_pointer.xy * sc;
vec2 toP = q - ptr;
float pull = u_pointer.z * exp(-dot(toP, toP) * 10.0);
vec2 qw = q - toP * pull * 0.22;
/* a slow domain warp keeps each light an organic blob rather than a
drawable ellipse — applied to the (already leaning) point, so it
deforms the merged body as one shape */
float t = u_time * 0.085;
vec2 warp = vec2(
fbm(qw * 1.9 + vec2(0.0, t)),
fbm(qw * 1.9 + vec2(4.3, -t) + 9.1)
);
qw += warp * 0.09;
float w1 = lightAt(u_light[0], qw, sc);
float w2 = lightAt(u_light[1], qw, sc);
float w3 = lightAt(u_light[2], qw, sc);
/* the pointer's own light: an equal member of the same merge, not an
overlay — it wins its share of the energy-weighted mean like any of
the other three */
vec4 touchLight = vec4(u_pointer.xy, 0.30, u_pointer.z * 1.2);
float w4 = lightAt(touchLight, q, sc);
float e = w1 + w2 + w3 + w4;
/* THE MERGE — see ContactField for the full rationale. One line of
physics, not a collision test: the energy-weighted mean of the four
colours, so an overlap is a genuinely new hue rather than a crossfade. */
vec3 tint = (w1 * u_c1 + w2 * u_c2 + w3 * u_c3 + w4 * u_touch) / max(e, 1e-4);
float shared = e - max(max(max(w1, w2), w3), w4);
float fused = smoothstep(0.08, 0.50, shared);
float core = smoothstep(0.25, 1.00, e);
float glow = 1.0 - exp(-e * 1.7);
vec3 col = u_ground;
col += tint * glow * (0.85 + 0.4 * core + 0.6 * fused);
/* a tight, crisp core exactly at the cursor — the difference between the
touch reading as "a light joined the field" and "the field brightened
somewhere near my mouse" */
vec2 dTouch = q - u_pointer.xy * sc;
float touchCore = exp(-dot(dTouch, dTouch) * 46.0) * u_pointer.z;
col += u_touch * touchCore * 0.55;
col = max(col, 0.0);
/* ---- the reading guard (see TileField for the full rationale) ---- */
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, holdUnder(col, 0.09), band * u_guard);
col += (bayer8(gl_FragCoord.xy) - 0.5) * (2.2 / 255.0);
gl_FragColor = vec4(col, 1.0);
}
`;
/* The three lights, aliased from the role palettes — same tokens
ContactField uses, in app/globals.css § "The closing field". */
const PALETTE = [
"--shader-contact-a",
"--shader-contact-b",
"--shader-contact-c",
] as const;
/** Deterministic PRNG, so a seed reproduces a walk exactly. */
function mulberry32(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let x = Math.imul(a ^ (a >>> 15), 1 | a);
x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x;
return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
};
}
interface Light {
/** Position along the arc, 0–1. The only degree of freedom it has. */
x: number;
min: number;
max: number;
v: number;
vTarget: number;
next: number;
rng: () => number;
phase: number;
radius: number;
}
/* Different base radii, so the three are not interchangeable and a merge
between a big one and a small one looks different from the reverse. */
const RADII = [0.36, 0.3, 0.34];
function makeLights(): Light[] {
return RADII.map((radius, i) => {
const rng = mulberry32(0x9e37 + i * 7919);
const [min, max] = SECTORS[i];
return {
x: (min + max) * 0.5,
min,
max,
v: 0,
vTarget: (i % 2 === 0 ? 1 : -1) * (0.035 + rng() * 0.05),
next: rng() * 1.5,
rng,
phase: rng() * 6.283,
radius,
};
});
}
const DT = 1 / 60;
function step(lights: Light[], time: number) {
for (const s of lights) {
if (time >= s.next) {
const dir =
s.rng() < 0.38 ? -Math.sign(s.vTarget || 1) : Math.sign(s.vTarget || 1);
s.vTarget = dir * (0.028 + s.rng() * 0.072);
s.next = time + 1.4 + s.rng() * 3.8;
}
s.v += (s.vTarget - s.v) * (1 - Math.exp(-DT / 0.55));
s.x += s.v * DT;
if (s.x < s.min) {
s.x = s.min + (s.min - s.x);
s.vTarget = Math.abs(s.vTarget);
} else if (s.x > s.max) {
s.x = s.max - (s.x - s.max);
s.vTarget = -Math.abs(s.vTarget);
}
}
}
function pack(lights: Light[], time: number, out: Float32Array) {
lights.forEach((s, i) => {
const drift =
0.052 * Math.sin(time * 0.37 + s.phase) +
0.03 * Math.sin(time * 0.23 + s.phase * 2.1);
const breathe = 0.92 + 0.1 * Math.sin(time * 0.44 + s.phase);
out[i * 4 + 0] = s.x;
out[i * 4 + 1] = arcY(s.x) + drift;
out[i * 4 + 2] = s.radius * breathe;
out[i * 4 + 3] = 1;
});
}
export interface ArcLightsFieldProps {
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 ArcLightsField({
className,
guardSelector = null,
}: ArcLightsFieldProps) {
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;
};
let lights = makeLights();
const packed = new Float32Array(12);
let simTime = 0;
/* Advances the walk TO a time rather than BY one — rewinding (what a
still draw does) starts the walk over from its seeds, so the frozen
frame and the running one are the same frame. */
const simulateTo = (time: number) => {
if (time < simTime) {
lights = makeLights();
simTime = 0;
}
let steps = Math.min(Math.floor((time - simTime) / DT), 1200);
while (steps-- > 0) {
simTime += DT;
step(lights, simTime);
}
};
return mountShaderSurface(canvas, {
fragment: FRAG,
colors: {
u_c1: PALETTE[0],
u_c2: PALETTE[1],
u_c3: PALETTE[2],
u_ground: "--iris-bg-sunken",
},
uniforms: ["u_light[0]", "u_touch", "u_readA", "u_guard"],
onInit: (gl, u) => {
if (u.u_touch) gl.uniform3f(u.u_touch, 0.97, 0.975, 0.99);
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) => {
simulateTo(s.time);
pack(lights, simTime, packed);
gl.uniform4fv(u["u_light[0]"], packed);
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 / EmberField: 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"),
stillTime: 9,
});
}, [guardSelector]);
return (
<div
className={`absolute inset-0 overflow-hidden${className ? ` ${className}` : ""}`}
aria-hidden="true"
>
<div className="absolute inset-0 [background:radial-gradient(_38%_62%_at_18%_68%,var(--shader-contact-a)_0%,transparent_68%_),radial-gradient(_42%_68%_at_50%_30%,var(--shader-contact-b)_0%,transparent_68%_),radial-gradient(_38%_62%_at_82%_68%,var(--shader-contact-c)_0%,transparent_68%_),var(--iris-bg-sunken)]" />
<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