Skip to content

IrisBackgroundsFulgur

Fulgur

A dense lightning current splitting into a full fractal canopy of forks, white-hot at the core and wrapped in magenta over a violet storm haze.

storm

Fulgur

Where `bolt` throws off half a dozen simple forks from its one current, this one carries two generations of them: a handful of primary branches leave the trunk and run most of the way to the frame's edges, and each primary throws its own pair of secondary forks partway along its own run — a Lichtenberg-figure canopy closer to what a long-exposure photo of a real strike actually shows. Every fork is white-hot at its own core, ringed in warm amber, fading out through hot magenta veins into a wide violet smoke that fills the rest of the frame. Both generations are read off fixed per-branch hashes, so the overall silhouette holds still frame to frame; only the fine zigzag wander inside each branch's own path keeps moving.

The animation is the storm rather than the branch layout: on top of the trunk's own crackle, a scheduled flicker — two or three pulses per cycle, irregular enough that a cycle can skip one entirely — periodically brightens the whole current and washes the sky a shade brighter, the way a real storm's strikes come in bursts rather than a steady glow.

No pointer interaction — the current holds its ground regardless of the cursor, same reasoning as `bolt`.

  • storm
  • dark
  • electric
  • fractal
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 { FulgurField } from "./FulgurField";

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">
      <FulgurField />
    </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 thunderstorm current — a single dense trunk splitting into a
 * full fractal canopy of forks, white-hot at the core with a warm amber ring
 * right around it, fading out through hot magenta veins into a wide violet
 * smoke that fills the rest of the frame.
 *
 * Where `BoltField` throws off half a dozen simple forks, this one carries
 * two generations of them: a handful of primary branches leave the trunk and
 * run most of the way to the frame's edges, and each primary throws its own
 * pair of secondary forks partway along its own run — a Lichtenberg-figure
 * canopy rather than a single current with a few side branches, closer to
 * what a long-exposure photo of a real strike actually shows. Unlike
 * `BoltField`, none of this geometry moves: the whole canopy is a single
 * frozen shape read off fixed per-branch hashes, the way a photograph of one
 * strike holds still rather than a current that keeps re-tracing itself.
 *
 * The animation is entirely the storm's brightness, never its shape: on top
 * of a fast per-segment crackle riding every branch, a shared flash envelope
 * — two octaves of smooth value noise shaped through a power curve, so it
 * has genuinely quiet stretches between clearly brighter surges rather than
 * a gentle constant breathing — periodically brightens the whole current
 * and washes the sky a shade brighter. Built from continuous noise rather
 * than a fixed-period scheduler, so there is no cycle boundary for a surge
 * to pop across; every rise and fall is itself a smooth curve, evaluated as
 * a direct function of `u_time`, so reduced motion's single still frame just
 * samples it once.
 *
 * No pointer interaction, and no drift either — the whole canopy holds its
 * ground regardless of the cursor or the clock, same reasoning as
 * `BoltField`: geometry that shifted or slowly rotated over time would read
 * as the composition itself being dragged around, not as something alive.
 *
 * 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-fulgurfield__floor`
 * underneath visible — a still strike in the same palette, never a blank box.
 *
 * Fixed violet/magenta/amber/white palette, passed as uniforms rather than
 * the accent ramp — this mood lives nowhere in the site's own amber ramp,
 * same reasoning as `BoltField` and `ThunderheadField`.
 *
 * 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.012, 0.004, 0.014], // near-black, faint violet cast
  u_hazeDeep: [0.15, 0.02, 0.13], // deep violet-magenta smoke, fills the frame
  u_hazeBright: [0.5, 0.06, 0.32], // brighter magenta smoke wrapping the current
  u_vein: [0.96, 0.3, 0.56], // the fork veins, hot pink-magenta
  u_warm: [0.98, 0.44, 0.16], // the warm amber ring right around the core
  u_core: [0.99, 0.97, 0.94], // white-hot core
};

const FRAG = `
uniform vec2  u_res;
uniform float u_time;
uniform float u_scale;

uniform vec3 u_ground;
uniform vec3 u_hazeDeep;
uniform vec3 u_hazeBright;
uniform vec3 u_vein;
uniform vec3 u_warm;
uniform vec3 u_core;

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);
}

/* A sharp, angular octave: linear interpolation between per-segment random
   offsets, so the path breaks at a hard corner on every segment boundary
   instead of easing through it — the way a real strike's path actually
   looks, a jagged run of straight kinked segments rather than a smooth
   curve. Same device BoltField uses. */
float zigzag(float p, float seed) {
  float i = floor(p);
  float f = fract(p);
  float a = hash11(i + seed) * 2.0 - 1.0;
  float b = hash11(i + 1.0 + seed) * 2.0 - 1.0;
  return mix(a, b, f);
}

/* The trunk's horizontal position at height t (0 top .. 1 bottom) — three
   fixed zigzag octaves, a slow coarse bend, a medium kink, a fine jitter,
   none of them a function of time. The whole strike's path is a single
   frozen shape, the way a photograph of one holds still: what animates is
   never the current's own position, only its brightness (see flicker
   below and the per-segment crackle in forkColor). */
float trunkX(float t) {
  float amp = smoothstep(0.0, 0.02, t);
  float bend = zigzag(t * 4.0, 4.0);
  float kink = zigzag(t * 11.0, 17.0);
  float jitter = zigzag(t * 30.0, 41.0);
  return (bend * 0.09 + kink * 0.035 + jitter * 0.012) * amp;
}

/* A fork's horizontal offset from its own origin at local progress bt (0 at
   the point it leaves its parent, growing outward from there) — independent
   of whether bt actually falls inside this fork's own visible span, so a
   secondary fork can query "where would this branch be at bt" past its own
   tip, which is exactly what locating a sub-fork's origin needs. Fixed, like
   trunkX — no time term, so the canopy's shape never drifts or rotates. */
float forkOffset(float bt, float side, float reach, float idx) {
  float amp = clamp(bt / 0.05, 0.0, 1.0);
  float k1 = zigzag(bt * 9.0 + idx * 3.1, idx * 13.0 + 2.0);
  float k2 = zigzag(bt * 23.0 + idx * 1.7, idx * 9.0 + 70.0);
  return side * bt * reach + (k1 * 0.05 + k2 * 0.02) * amp;
}

/* Fixed per-primary-branch layout: where it leaves the trunk, which side it
   takes, how far it reaches and how long it runs — hashed off idx alone, so
   this shape holds still frame to frame. */
void primaryParams(float idx, out float bStart, out float side, out float reach, out float len) {
  bStart = 0.04 + hash11(idx * 12.9 + 1.0) * 0.62;
  side = hash11(idx * 7.7 + 4.0) > 0.5 ? 1.0 : -1.0;
  reach = 0.55 + hash11(idx + 9.0) * 0.75;
  len = 0.16 + hash11(idx * 5.3 + 2.0) * 0.34;
}

/* Fixed per-secondary-fork layout, relative to its own parent primary: where
   along the primary's own run (in the primary's local bt units) it splits
   off, which side, how far and how long. */
void secondaryParams(float pIdx, float sIdx, float pLen, out float secStart, out float side, out float reach, out float len) {
  float sid = pIdx * 10.0 + sIdx;
  secStart = pLen * (0.32 + hash11(sid * 3.3 + 1.0) * 0.42);
  side = hash11(sid * 8.1 + 5.0) > 0.5 ? 1.0 : -1.0;
  reach = 0.32 + hash11(sid + 13.0) * 0.5;
  len = 0.08 + hash11(sid * 6.1 + 3.0) * 0.18;
}

/* The colour ramp every current — trunk or fork — reads its glow off: white
   at the core, through the warm amber ring, out into the hot magenta a real
   bolt's ionised air glows. distN is distance from the current's own centre
   line in units of its local half-width, so the same ramp works at any
   width without its own set of tuning constants. */
vec3 currentRamp(float distN) {
  vec3 c = mix(u_core, u_warm, smoothstep(0.0, 0.85, distN));
  return mix(c, u_vein, smoothstep(0.6, 2.3, distN));
}

/* One fork's colour at pixel P, height t — main trunk and every branch use
   the same shape: a tight bright centre over a much softer, wider tail,
   tapering in near its own start and fading out near its own tip, with a
   fast per-segment crackle so it never reads as a smooth static line. flick
   is the storm's shared flash envelope, folded in so every live fork
   brightens together. */
vec3 forkColor(vec2 P, float t, float originX, float time, float bStart, float side, float reach, float len, float idx, float widthScale, float flick) {
  float bt = t - bStart;
  if (bt < 0.0 || bt > len) return vec3(0.0);
  float x = originX + forkOffset(bt, side, reach, idx);
  float d = abs(P.x - x);
  float prog = clamp(bt / len, 0.0, 1.0);
  float w = mix(0.0052, 0.0016, prog) * widthScale;
  float distN = d / w;
  float taper = smoothstep(0.0, 0.06, prog) * (1.0 - smoothstep(0.72, 1.0, prog));
  float crackle = 0.7 + 0.4 * hash11(idx * 3.7 + floor(bt * 46.0 + time * 3.0));
  float intensity = (exp(-distN * distN * 5.0) + 0.4 * exp(-distN * distN * 0.4))
    * taper * crackle * (0.45 + flick);
  return currentRamp(distN) * intensity;
}

/* The full canopy: eight primary branches off the trunk, each carrying two
   secondary forks partway along its own run — a Lichtenberg-figure fan
   rather than a handful of isolated side branches. The layout itself never
   moves; only crackle (above) and flick (the storm's shared flash, below)
   animate it. */
vec3 branchesColor(vec2 P, float t, float time, float flick) {
  vec3 total = vec3(0.0);
  for (int i = 0; i < 8; i++) {
    float idx = float(i);
    float bStart, side, reach, len;
    primaryParams(idx, bStart, side, reach, len);
    float originX = trunkX(bStart);
    total += forkColor(P, t, originX, time, bStart, side, reach, len, idx, 1.0, flick);

    for (int j = 0; j < 2; j++) {
      float jf = float(j);
      float secStart, side2, reach2, len2;
      secondaryParams(idx, jf, len, secStart, side2, reach2, len2);
      float subBStart = bStart + secStart;
      float subOriginX = originX + forkOffset(secStart, side, reach, idx);
      float sIdx = idx * 10.0 + jf + 100.0;
      total += forkColor(P, t, subOriginX, time, subBStart, side2, reach2, len2, sIdx, 0.62, flick) * 0.8;
    }
  }
  return total;
}

/* The storm's shared flash envelope — how visibly the whole current and its
   canopy surge and settle. Built from two octaves of smooth value noise
   (fbm, continuous everywhere by construction) rather than a fixed-period
   scheduler, so there is no cycle boundary for a pulse to pop across: every
   rise and fall is itself a smooth curve, the way a storm's brightness
   actually swells and fades rather than snapping between fixed states.
   Shaped through a power curve so the quiet stretches between surges stay
   genuinely low and the surges themselves read as clearly brighter, not a
   gentle breathing. A direct function of u_time rather than an integrated
   simulation, so reduced motion's single still frame just samples it once. */
float flicker(float time) {
  float slow = fbm(vec2(time * 0.6, 11.0));
  float fast = fbm(vec2(time * 1.9, 47.0));
  float n = clamp(slow * 0.6 + fast * 0.4 + 0.5, 0.0, 1.0);
  return pow(n, 3.0) * 2.2;
}

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 ambient smoke's own slow drift — atmosphere, not the strike, so it
     is the one thing here still allowed to move sideways over time */
  vec2 drift = vec2(u_time * 0.012, -u_time * 0.008);
  float flick = flicker(u_time);

  float t = clamp(0.5 - P.y, 0.0, 1.0);

  float xTrunk = trunkX(t);
  float d = abs(P.x - xTrunk);

  float crackle = 0.85 + 0.15 * fbm(vec2(t * 24.0, u_time * 1.5));
  float trunkW = mix(0.011, 0.0055, t);
  float distN = d / trunkW;
  float trunkIntensity = (exp(-distN * distN * 5.0) + 0.4 * exp(-distN * distN * 0.35))
    * crackle * (0.45 + flick);
  vec3 trunkGlow = currentRamp(distN) * trunkIntensity;

  vec3 branches = branchesColor(P, t, u_time, flick);

  /* the ambient violet smoke filling the whole frame, drifting on its own */
  float ambientN = fbm(P * 0.85 + drift + 5.0);
  vec3 col = u_ground;
  col += u_hazeDeep * (0.3 + 0.34 * clamp(ambientN * 0.5 + 0.5, 0.0, 1.0));

  /* two tiers of magenta haze around the current: a broad diffuse cloud
     standing in for the storm's own glow filling the sky, and a tighter
     wrap right around the trunk for the glow real ionised air holds
     against a live current */
  float hazeN = fbm(P * 2.4 + drift * 1.6 + 9.0);
  float hazeWide = exp(-d * d / (0.55 * 0.55)) * (0.3 + 0.4 * clamp(hazeN * 0.5 + 0.5, 0.0, 1.0));
  float hazeTight = exp(-d * d / (0.16 * 0.16)) * (0.35 + 0.5 * clamp(hazeN * 0.5 + 0.5, 0.0, 1.0));
  col += u_hazeBright * (hazeWide * 0.55 + hazeTight) * (0.45 + flick);

  col += trunkGlow + branches;

  /* the storm's own flash: a soft, near-white wash across the whole frame
     that swells with the same flick envelope as the current itself, the way
     a real strike briefly lights the whole sky rather than just its own
     path — this is the difference visitors actually read as "storm" rather
     than "a bolt flickering". */
  col += mix(u_hazeBright, vec3(1.0), 0.4) * flick * 0.16;

  float vig = 1.0 - smoothstep(0.55, 1.2, length(uv - 0.5));
  col *= mix(0.6, 1.0, vig);

  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.1), guardBand * u_guard);

  col += (bayer8(gl_FragCoord.xy) - 0.5) * (2.2 / 255.0);

  gl_FragColor = vec4(col, 1.0);
}
`;

export interface FulgurFieldProps {
  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 FulgurField({ className, guardSelector = null }: FulgurFieldProps) {
  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 BoltField / ThunderheadField: 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: 5.2,
    });
  }, [guardSelector]);

  return (
    <div
      className={`absolute inset-0 overflow-hidden${className ? ` ${className}` : ""}`}
      aria-hidden="true"
    >
      <div className="absolute inset-0 [background:linear-gradient(_180deg,oklch(0.97_0.01_40_/_0.85)_0%,oklch(0.72_0.2_30_/_0.5)_8%,oklch(0.55_0.24_350_/_0.4)_22%,transparent_42%_),radial-gradient(_3%_100%_at_50%_50%,oklch(0.82_0.14_350_/_0.55)_0%,transparent_62%_),radial-gradient(_50%_60%_at_32%_30%,oklch(0.32_0.2_335_/_0.55)_0%,transparent_72%_),radial-gradient(_52%_62%_at_68%_66%,oklch(0.28_0.2_330_/_0.5)_0%,transparent_74%_),radial-gradient(_70%_50%_at_50%_50%,oklch(0.18_0.12_320_/_0.4)_0%,transparent_80%_),oklch(0.014_0.01_330)] before:content-[''] before:absolute before:inset-0 before:[background:radial-gradient(_50%_50%_at_6%_8%,transparent_0%,oklch(0.012_0.008_330_/_0.9)_100%_),radial-gradient(_50%_50%_at_94%_92%,transparent_0%,oklch(0.012_0.008_330_/_0.9)_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