IrisBackgroundsSpindrift
Spindrift
A swarm of light beads spiralling out from one off-centre point, big and soft up close, small and sharp far out; the pointer stirs an eddy through them.
Spindrift
A long-exposure of blown particles caught mid-spiral: cool-white beads with a scatter of warm amber stream out along curving arms from one off-centre emission point, over black. The angle of each arm is bent by distance so the streams wind into a spiral rather than radiating straight, and the whole spiral turns slowly. Three depth layers run at rising frequency and falling blur, so near the camera the beads are big, soft and dim — out-of-focus bokeh — and far out they resolve to small, sharp, bright points.
Near the cursor the arms wind tighter and their beads run outward faster, a whirl dragged through the stream; the emission point itself drifts a little toward the pointer, so the whole spiral shifts under you. The cursor drops a warm bloom with a near-white core.
Install
No installation needed — self-contained, paste-in code.
Usage
Drop it straight into a page.
import { SpindriftField } from "./SpindriftField";
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">
<SpindriftField />
</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 swarm of light beads streaming out along curving arms from one
* off-centre emission point — the reference is a long-exposure of blown
* particles caught mid-spiral, mostly cool white with a scatter of warm
* amber, over black, everything falling in and out of focus. Near the camera
* the beads are big, soft and dim (out-of-focus bokeh); far out they resolve
* to small, sharp, bright points.
*
* The geometry is one polar field around the emission point `E`: the angle
* is bent by radius (`ang + r * curl`) so the arms wind into a spiral rather
* than radiating straight, and the whole spiral turns slowly (`t * spin`).
* Beads are the product of an angular gaussian (the arm) and a radial one
* (`fract(r * freq - t * speed)`), so they travel outward down their arm as
* the pattern animates. Three depth layers run this at rising frequency and
* falling blur, which is what gives the shot its depth of field.
*
* Two pointer interactions, both stirring the swarm rather than lighting it:
* - eddy — near the cursor the arms wind tighter and the beads on them run
* faster outward, a little whirl dragged through the stream (a bounded
* phase nudge, not a speed multiplier — see `pull`).
* - parallax — the emission point drifts a touch toward the pointer, so the
* whole spiral shifts under you as you move across it.
* A third, smaller touch: the cursor drops a warm bloom with a near-white
* core, the same "your touch joined the light" move the other fields make.
*
* One of the reusable background fields (`TileField`, `SilkField`,
* `FilamentField`, `EmberField`, `ContraflowField`). 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-spindriftfield__floor` underneath visible (a
* still composed frame in the same palette, never a blank box).
*
* Like `EmberField` / `FilamentField` (and unlike `SilkField`), the palette
* is a fixed set passed as uniforms rather than read from the token ramp —
* the near-white bead core lives nowhere in the accent ramp, so the CSS
* floor carries the colour rather than a live token read.
*
* 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), so copy laid
* over the field holds WCAG AA with no overlay layer. `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_ground: [0.008, 0.008, 0.009], // the near-black behind everything
u_dust: [0.11, 0.11, 0.12], // the dim, out-of-focus beads
u_bead: [0.84, 0.85, 0.88], // a lit cool-white bead
u_beadHot: [1.0, 0.99, 0.97], // the near-white core on the sharpest ones
u_ember: [0.46, 0.17, 0.05], // the dim warm beads
u_amber: [0.99, 0.44, 0.13], // a lit amber bead and the pointer bloom
};
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_ground;
uniform vec3 u_dust;
uniform vec3 u_bead;
uniform vec3 u_beadHot;
uniform vec3 u_ember;
uniform vec3 u_amber;
/* 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;
const float PI = 3.14159265;
const float ARMS = 20.0;
/* One depth layer of the bead swarm. 'd' is the pixel's offset from the
emission point (P-space, aspect-corrected). 'kick' is a bounded outward
phase nudge from the cursor's eddy; 'wind' bends the local arm angle.
Writes the near-white core of the sharpest beads into 'core'. */
float layer(vec2 d, float freq, float speed, float curl, float spin,
float soft, float t, float kick, float wind, out float core) {
core = 0.0;
float r = length(d) + 1e-4;
float ang = atan(d.y, d.x);
/* wind the arms into a spiral and turn the whole thing slowly; the eddy
adds its own local twist */
float a = (ang + r * curl + t * spin + wind) / (2.0 * PI) * ARMS;
float armId = floor(a);
float af = fract(a) - 0.5; /* angular offset from arm centre */
float aid = mod(armId, ARMS); /* wrap so atan's seam doesn't show */
/* beads spaced along the arm in radius, travelling outward with time */
float rr = r * freq - t * speed * freq - kick;
float cellId = floor(rr);
float rc = fract(rr) - 0.5;
/* per-bead random — some missing, some warm, some fat and dim (bokeh) */
float h1 = dotHash(vec2(aid, cellId));
float h2 = dotHash(vec2(cellId * 1.7 + 4.0, aid * 2.3));
if (h1 < 0.44) return 0.0; /* gap in the stream — mostly black */
/* ~9% are big soft bokeh — but only out where an arm is wide enough to
hold one without smearing across the whole angular slot */
float fat = step(0.91, h2) * step(0.42, r);
/* one round bead: angular offset and along-arm offset both taken to
r-space (screen) distance, so the dot is a circle rather than a radial
smear. r is clamped hard in the angular term so beads near the emission
point keep a finite width instead of fanning into a wedge. */
float armW = af * max(r, 0.34) * (2.0 * PI / ARMS);
float alongW = rc / freq; /* radial distance to the cell centre */
float rad2 = (armW * armW + alongW * alongW);
float k = mix(5600.0, 900.0, fat) * soft;
float bead = exp(-rad2 * k);
float bright = (0.35 + 0.65 * h2) * (fat > 0.5 ? 0.45 : 1.0);
/* the very centre is a dark well; the far edge falls off gently */
bright *= smoothstep(0.13, 0.36, r) * smoothstep(2.1, 0.6, r);
core = bead * bright * smoothstep(0.4, 0.9, h2) * (fat > 0.5 ? 0.0 : 1.0);
return bead * bright;
}
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);
vec2 ptr = (u_pointer.xy - 0.5) * vec2(aspect, 1.0);
vec2 toPtr = P - ptr;
float dPtr = dot(toPtr, toPtr);
/* the eddy: a bounded pull that winds the local arms and runs their beads
outward faster. Kept as a phase nudge, never a speed multiplier — a
spatially-varying multiplier compounds against absolute run time and
shreds the pattern near the cursor after a while (see CurrentField). */
float pull = u_pointer.z * exp(-dPtr * 20.0);
float kick = pull * 1.8;
float wind = pull * 1.9;
/* parallax: the emission point drifts toward the pointer */
vec2 E = vec2(-0.05 * aspect, 0.12)
+ (ptr - vec2(-0.05 * aspect, 0.12)) * u_pointer.z * 0.08;
vec2 d = P - E;
vec3 col = u_ground;
float b0c, b1c, b2c;
float b0 = layer(d, 6.0, 0.09, 0.95, 0.028, 0.8, u_time, kick, wind, b0c);
float b1 = layer(d, 10.0, 0.13, 1.25, 0.048, 1.0, u_time, kick, wind, b1c);
float b2 = layer(d, 17.0, 0.17, 1.60, 0.076, 1.3, u_time, kick, wind, b2c);
/* per-layer tint: mostly cool-white beads, a scatter of amber. The amber
share rises a little on the near, blurry layer, the way the reference's
warm blobs sit closest to the lens. */
float r = length(d);
float warm0 = smoothstep(0.62, 0.9, dotHash(vec2(floor(r * 5.0), 1.0)));
float warm1 = smoothstep(0.72, 0.95, dotHash(vec2(floor(r * 9.0), 7.0)));
float warm2 = smoothstep(0.78, 0.97, dotHash(vec2(floor(r * 15.0), 3.0)));
col += mix(u_dust, mix(u_bead, u_amber, warm0), 0.5) * b0 * 0.5;
col += mix(u_bead, u_amber, warm1) * b1 * 1.4;
col += mix(u_bead, u_amber, warm2) * b2 * 1.7;
col += u_beadHot * (b1c + b2c) * 0.9;
col += u_ember * mix(warm1, warm2, 0.5) * (b1 + b2) * 0.18; /* faint warm halo */
/* a faint radial haze so the emission point reads as a source, not a hole */
col += mix(u_ember, u_amber, 0.3) * exp(-r * r * 7.0) * 0.06;
/* the touch: a warm bloom with a near-white core */
col += u_amber * u_pointer.z * exp(-dPtr * 11.0) * 0.4;
col += u_beadHot * u_pointer.z * exp(-dPtr * 46.0) * 0.32;
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 md = mix(max(rd.x, rd.y), length(rd), 0.4);
float band = 1.0 - smoothstep(0.72, 2.1, md);
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);
}
`;
export interface SpindriftFieldProps {
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 SpindriftField({
className,
guardSelector = null,
}: SpindriftFieldProps) {
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 EmberField / ContraflowField: 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"
data-print="hide"
>
<div className="absolute inset-0 [background:radial-gradient(_26%_30%_at_45%_62%,oklch(0.5_0.16_55_/_0.5)_0%,transparent_70%_),radial-gradient(_70%_75%_at_45%_62%,oklch(0.28_0.05_45_/_0.5)_0%,transparent_72%_),conic-gradient(_from_20deg_at_45%_62%,oklch(0.4_0.02_60_/_0.5)_0deg,transparent_12deg,transparent_14deg,oklch(0.4_0.02_60_/_0.5)_26deg,transparent_38deg,transparent_40deg,oklch(0.4_0.02_60_/_0.5)_52deg,transparent_64deg,transparent_66deg,oklch(0.5_0.1_55_/_0.5)_78deg,transparent_90deg,transparent_92deg,oklch(0.4_0.02_60_/_0.5)_104deg,transparent_116deg,transparent_340deg,oklch(0.4_0.02_60_/_0.5)_352deg,transparent_360deg_),radial-gradient(_120%_120%_at_45%_62%,oklch(0.05_0.01_40)_0%,transparent_65%_),oklch(0.02_0.005_40)] after:content-[''] after:absolute after:inset-0 after:[background:repeating-radial-gradient(_circle_at_45%_62%,transparent_0_14px,oklch(0.86_0.02_80_/_0.16)_14px_18px_)] after:[-webkit-mask-image:radial-gradient(_65%_65%_at_45%_62%,transparent_0_8%,#000_34%,transparent_88%_)] after:[mask-image:radial-gradient(_65%_65%_at_45%_62%,transparent_0_8%,#000_34%,transparent_88%_)]" />
<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