IrisBackgroundsThunderhead
Thunderhead
A drifting mass of blue-black storm cloud, lit by lightning that starts aiming for wherever the pointer rests — and a click brings one down on demand.
Thunderhead
A night storm built from drifting fbm cloud rather than a painted texture — near-black between the masses, cooling into a moonlit blue through the body of each cloud, and flaring to a near-white rim wherever the density peaks, the way real storm cloud catches ambient light along its own edges.
Lightning strikes up to three at a time, each starting from its own point along the top and forking down through its own seeded, branching path, lit by a short burst of flicker rather than a single flash — never fully idle for long. Each strike briefly lights the whole sky a shade brighter, the way a real bolt does.
The interaction IS the lightning: hovering pulls each new strike's origin toward the cursor's own position, so the storm visibly starts aiming for wherever a visitor points, without ever snapping fully to it — and a click or tap brings a bolt down immediately, exactly there. The cloud itself never reacts to the pointer; the lightning is the whole answer.
Install
No installation needed — self-contained, paste-in code.
Usage
Drop it straight into a page.
import { ThunderheadField } from "./ThunderheadField";
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">
<ThunderheadField />
</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 night storm — drifting fbm cloud mass in blue, black and
* white, lit from within by lightning that forks down from the top on its
* own random schedule, never the same place twice in a row.
*
* Up to three bolts can be alight at once, each a short burst of two-to-four
* exponential flicker pulses standing in for a real bolt's re-strike. The
* bolt's own path, its taper, and its two forking branches are all read off
* a per-strike seed procedurally in GLSL, so no polyline ever crosses the
* CPU/GPU boundary — only `(originX, seed, envelope)` per strike does.
*
* The interaction IS the lightning, not a separate effect layered on top of
* the cloud: hovering pulls each new ambient strike's origin toward the
* cursor's own x position — the storm visibly starts aiming for wherever a
* visitor points, without ever being locked to it — and a click or tap
* fires an immediate fourth bolt right there, on demand. The cloud itself
* never reacts to the pointer; the lightning is the whole answer.
*
* Scheduling is a direct time comparison rather than integration, so
* rewinding to a fixed still frame (reduced motion) just replays the same
* schedule from zero — see `simulateTo` below, the same rewind contract
* `ArcLightsField`'s walk uses for the same reason. The pointer bias reads
* `FrameState.pointer`, which is forced to zero presence for that same
* still frame, so reduced motion still reproduces deterministically.
*
* One of the reusable background fields. 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-thunderheadfield__floor`
* underneath visible — a still storm in the same palette, never a blank box.
*
* Fixed blue/black/white palette, passed as uniforms rather than the accent
* ramp — this mood lives nowhere in the site's own amber ramp, same
* reasoning as `DuskBloomField`.
*
* 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.
*/
/* Palette, sRGB 0–1. Uniforms, not tokens — see the note above. */
const PALETTE: Record<string, [number, number, number]> = {
u_ground: [0.007, 0.009, 0.018], // near-black night sky between clouds
u_cloudDark: [0.03, 0.043, 0.08], // cloud shadow
u_cloudMid: [0.13, 0.18, 0.31], // cloud body, moonlit blue
u_cloudLight: [0.84, 0.89, 0.97], // cloud rim, near-white
u_boltCore: [0.97, 0.98, 1.0], // the bolt's white core
u_boltHalo: [0.52, 0.7, 1.0], // the bolt's electric-blue halo
u_flash: [0.6, 0.75, 1.0], // the sky-wide flash tint
};
const FRAG = `
uniform vec2 u_res;
uniform float u_time;
uniform float u_scale;
uniform vec3 u_ground;
uniform vec3 u_cloudDark;
uniform vec3 u_cloudMid;
uniform vec3 u_cloudLight;
uniform vec3 u_boltCore;
uniform vec3 u_boltHalo;
uniform vec3 u_flash;
/* Per strike: x = origin (uv 0..1, across the top), y = seed, z = envelope
0..1. Slots 0-2 are the ambient schedule, slot 3 is the click/tap bolt —
all scheduled and enveloped on the CPU, see the component below. */
uniform vec3 u_strike[4];
uniform vec4 u_readA;
uniform float u_guard;
float hash11(float p) {
p = fract(p * 0.1031);
p *= p + 33.33;
p *= p + p;
return fract(p);
}
/* The bolt's horizontal offset at fall-distance t from its origin — a
two-octave fbm walk plus a slow sinusoidal drift, both ramped in from
zero near the origin so the bolt actually starts at a point rather than
fading in from a wide band. */
float boltX(float t, float ox, float seed) {
float amp = clamp(t / 0.14, 0.0, 1.0) * clamp(t, 0.0, 1.0);
float n1 = fbm(vec2(t * 5.5 + seed * 3.7, seed * 1.9));
float n2 = fbm(vec2(t * 12.0 + seed * 8.1, seed * 2.3 + 50.0));
float drift = sin(t * 1.7 + seed * 6.2831) * 0.06;
return ox + (n1 * 0.5 + n2 * 0.5) * 0.22 * amp + drift * amp;
}
/* A branch forks from the main path at t = bStart and wanders off to one
side under its own, steeper noise. */
float branchX(float t, float bStart, float ox, float seed, float side) {
float bt = t - bStart;
float amp = clamp(bt / 0.1, 0.0, 1.0);
float n1 = fbm(vec2(bt * 8.0 + seed * 5.3, seed * 4.1 + 20.0));
float forkX = boltX(bStart, ox, seed);
return forkX + side * (bt * 0.35 + n1 * 0.12) * amp;
}
/* One strike's full contribution — main path plus two branches — and its
share of the sky-wide flash, accumulated into the flash argument. */
vec3 strikeGlow(vec2 P, float aspect, vec3 strike, inout float flash) {
float amt = strike.z;
if (amt < 0.003) return vec3(0.0);
float seed = strike.y;
float ox = (strike.x - 0.5) * aspect;
float originY = 0.58;
float depth = 0.55 + hash11(seed) * 0.5;
float t = clamp(originY - P.y, 0.0, depth);
float inSpan = step(0.0, originY - P.y) * step(originY - P.y, depth);
float edgeFade = smoothstep(0.0, 0.05, t) * (1.0 - smoothstep(depth * 0.82, depth, t));
float span = inSpan * edgeFade;
float x = boltX(t, ox, seed);
float d = abs(P.x - x);
float coreW = 0.0026 + 0.0018 * (t / depth);
float haloW = 0.026 + 0.05 * (t / depth);
float coreGlow = exp(-d * d / (coreW * coreW)) * span;
float haloGlow = exp(-d * d / (haloW * haloW)) * span;
float bStart1 = depth * (0.28 + hash11(seed + 1.0) * 0.18);
float bStart2 = depth * (0.48 + hash11(seed + 2.0) * 0.22);
float side1 = hash11(seed + 3.0) > 0.5 ? 1.0 : -1.0;
float side2 = hash11(seed + 4.0) > 0.5 ? 1.0 : -1.0;
float bx1 = branchX(t, bStart1, ox, seed + 11.0, side1);
float bx2 = branchX(t, bStart2, ox, seed + 21.0, side2);
float bSpan1 = step(bStart1, t) * step(t, min(depth, bStart1 + depth * 0.35)) * edgeFade;
float bSpan2 = step(bStart2, t) * step(t, min(depth, bStart2 + depth * 0.3)) * edgeFade;
float bd1 = abs(P.x - bx1);
float bd2 = abs(P.x - bx2);
float bGlow1 = exp(-bd1 * bd1 / (0.02 * 0.02)) * bSpan1;
float bGlow2 = exp(-bd2 * bd2 / (0.018 * 0.018)) * bSpan2;
float coreAll = coreGlow + bGlow1 * 0.55 + bGlow2 * 0.5;
float haloAll = haloGlow + bGlow1 * 0.65 + bGlow2 * 0.6;
flash += amt * span * 0.6 + amt * (bSpan1 + bSpan2) * 0.15;
return (u_boltCore * coreAll + u_boltHalo * haloAll) * amt;
}
void main() {
vec2 res = u_res / u_scale;
vec2 uv = gl_FragCoord.xy / u_scale / res; /* 0..1, y up */
float aspect = res.x / max(res.y, 1.0);
vec2 P = (uv - 0.5) * vec2(aspect, 1.0);
/* the cloud itself never reacts to the pointer — see the doc comment:
the interaction lives entirely in where the lightning goes */
vec2 flow = vec2(u_time * 0.014, u_time * 0.007);
float base = fbm(P * 1.55 + flow);
float detail = fbm(P * 3.4 - flow * 1.8 + 5.0);
float density = base * 0.65 + detail * 0.35;
float shade = smoothstep(-0.2, 0.82, density);
vec3 cloud = mix(u_cloudDark, u_cloudLight, shade);
float blueDrift = fbm(P * 2.1 + 11.0 - flow * 0.6);
cloud = mix(cloud, u_cloudMid, smoothstep(0.1, 0.75, blueDrift) * 0.5);
vec3 col = mix(u_ground, cloud, smoothstep(-0.62, -0.02, density));
float vig = 1.0 - smoothstep(0.32, 0.98, length(P));
col *= mix(0.62, 1.0, vig);
float flash = 0.0;
col += strikeGlow(P, aspect, u_strike[0], flash);
col += strikeGlow(P, aspect, u_strike[1], flash);
col += strikeGlow(P, aspect, u_strike[2], flash);
col += strikeGlow(P, aspect, u_strike[3], flash);
/* the flash lights the whole sky, faintly, the way a real strike does */
col += u_flash * min(flash, 1.3) * 0.16;
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 guardBand = 1.0 - smoothstep(0.72, 2.1, md);
col = mix(col, holdUnder(col, 0.09), guardBand * u_guard);
col += (bayer8(gl_FragCoord.xy) - 0.5) * (2.2 / 255.0);
gl_FragColor = vec4(col, 1.0);
}
`;
/** Deterministic PRNG, so a seed reproduces a schedule 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 Pulse {
t: number;
a: number;
decay: number;
}
interface Strike {
rng: () => number;
originX: number;
seed: number;
/** Start of the current flash, or -1 while idle waiting for `nextTime`. */
startTime: number;
endTime: number;
/** Next flash's start time, valid only while idle. Unused by the
* click-triggered slot, which is fired directly instead. */
nextTime: number;
pulses: Pulse[];
}
const STRIKE_SLOTS = 3;
const STRIKE_DURATION = 0.55;
/** How strongly a fresh ambient strike's origin gets pulled toward the
* cursor, at full pointer presence — never fully locked to it, so the
* storm still reads as aiming rather than snapping. */
const CURSOR_BIAS = 0.72;
function genPulses(rng: () => number): Pulse[] {
const n = 2 + Math.floor(rng() * 3);
const pulses: Pulse[] = [];
let t = 0;
for (let k = 0; k < n; k++) {
t += rng() * 0.05 + (k === 0 ? 0 : 0.03);
pulses.push({ t, a: 0.6 + rng() * 0.5, decay: 0.03 + rng() * 0.05 });
}
return pulses;
}
function makeStrike(rngSeed: number): Strike {
const rng = mulberry32(rngSeed);
return {
rng,
originX: 0.5,
seed: 0,
startTime: -1,
endTime: -1,
nextTime: rng() * 4,
pulses: [],
};
}
function makeStrikes(): Strike[] {
return Array.from({ length: STRIKE_SLOTS }, (_, i) => makeStrike(0x51a3 + i * 7919));
}
/** Advances one ambient strike's schedule up to `time`, pulling a freshly
* triggered strike's origin toward the cursor. Bounded, not integrated —
* each transition is a direct time comparison, so a large jump forward (a
* still-frame rewind fast-forwarding from zero) just replays every
* transition in between rather than needing substeps. */
function advanceStrike(
s: Strike,
time: number,
pointer: { x: number; presence: number }
) {
for (let guard = 0; guard < 64; guard++) {
if (s.startTime < 0) {
if (time < s.nextTime) return;
s.startTime = s.nextTime;
s.endTime = s.startTime + STRIKE_DURATION;
const bias = CURSOR_BIAS * pointer.presence;
s.originX = s.rng() * (1 - bias) + pointer.x * bias;
s.seed = s.rng() * 1000;
s.pulses = genPulses(s.rng);
} else {
if (time < s.endTime) return;
s.nextTime = s.endTime + 2.5 + s.rng() * 6.5;
s.startTime = -1;
}
}
}
function fireStrike(s: Strike, time: number, originX: number) {
s.startTime = time;
s.endTime = time + STRIKE_DURATION;
s.originX = originX;
s.seed = s.rng() * 1000;
s.pulses = genPulses(s.rng);
}
function envelope(s: Strike, time: number): number {
if (s.startTime < 0 || time < s.startTime || time > s.endTime) return 0;
const el = time - s.startTime;
let v = 0;
for (const p of s.pulses) {
const dt = el - p.t;
if (dt < 0) continue;
v += p.a * Math.exp(-dt / p.decay);
}
return Math.min(1, v);
}
function pack(strikes: Strike[], time: number, out: Float32Array) {
strikes.forEach((s, i) => {
out[i * 3 + 0] = s.originX;
out[i * 3 + 1] = s.seed;
out[i * 3 + 2] = envelope(s, time);
});
}
export interface ThunderheadFieldProps {
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 ThunderheadField({ className, guardSelector = null }: ThunderheadFieldProps) {
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 strikes = makeStrikes();
let cursorStrike = makeStrike(0xc0ffee);
let simTime = 0;
let pendingTapX: number | null = null;
const packed = new Float32Array(12);
const onTap = (e: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
if (rect.width === 0) return;
pendingTapX = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
};
canvas.addEventListener("pointerdown", onTap, { passive: true });
const simulateTo = (time: number, pointer: { x: number; presence: number }) => {
if (time < simTime) {
strikes = makeStrikes();
cursorStrike = makeStrike(0xc0ffee);
simTime = 0;
pendingTapX = null;
}
simTime = time;
for (const s of strikes) advanceStrike(s, time, pointer);
if (pendingTapX != null) {
fireStrike(cursorStrike, time, pendingTapX);
pendingTapX = null;
}
};
const teardown = mountShaderSurface(canvas, {
fragment: FRAG,
uniforms: [...Object.keys(PALETTE), "u_strike[0]", "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) => {
simulateTo(s.time, s.pointer);
pack(strikes, simTime, packed);
packed[9] = cursorStrike.originX;
packed[10] = cursorStrike.seed;
packed[11] = envelope(cursorStrike, simTime);
gl.uniform3fv(u["u_strike[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 DuskBloomField / ArcLightsField: 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_000_000,
dprCap: 1.5,
stillTime: 6.4,
});
return () => {
canvas.removeEventListener("pointerdown", onTap);
teardown();
};
}, [guardSelector]);
return (
<div
className={`absolute inset-0 overflow-hidden${className ? ` ${className}` : ""}`}
aria-hidden="true"
>
<div className="absolute inset-0 [background:radial-gradient(_3%_22%_at_58%_2%,oklch(0.96_0.02_250_/_0.9)_0%,oklch(0.7_0.12_250_/_0.45)_35%,transparent_70%_),radial-gradient(_46%_30%_at_30%_20%,oklch(0.55_0.08_250_/_0.55)_0%,transparent_70%_),radial-gradient(_50%_34%_at_74%_60%,oklch(0.4_0.1_255_/_0.5)_0%,transparent_72%_),radial-gradient(_60%_40%_at_50%_90%,oklch(0.3_0.07_255_/_0.4)_0%,transparent_75%_),oklch(0.02_0.01_260)] before:content-[''] before:absolute before:inset-0 before:[background:radial-gradient(_45%_45%_at_8%_95%,transparent_0%,oklch(0.01_0.005_260_/_0.92)_100%_),radial-gradient(_45%_45%_at_95%_5%,transparent_0%,oklch(0.01_0.005_260_/_0.92)_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