Skip to content

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.

storm

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.

  • cool
  • pointer-driven
  • storm
  • dark
Family
storm
Status
Available
Licence
Free

Install

No installation needed — self-contained, paste-in code.

Usage

Drop it straight into a page.

example.tsx
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>
  );
}

More backgrounds

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

Fardin Omor Afnan

Reads and answers every request himself

Or start with a section.

Whole page sections, composed and ready to drop in — take one and build the rest of the page around it.

Browse sections