GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

9 Lightning Effect Background Scenes, No WebGL

No library, no WebGL — a single recursive midpoint-displacement split makes lightning branches fork on their own, and that's the whole trick behind every

Auto-plays · click a tile to jump to its section · all nine in one zip

Why splitting one segment branches into nine bolts

The nine scenes climb one axis: how much of the frame each strike recomputes. 01's single stem is one recursive midpoint-displacement call, seeded once per strike. 02 keeps that same displace() call but adds a coin flip at every segment, so generation depth alone decides whether a fork survives. 03 moves the randomness from space to time — the recursion stays put, but an exponential-distribution wait decides when the next of several bolts fires, the same clustering math that runs a heat-diffusion terrain field, recomputing its neighbors on a schedule. 04 throws the stem's own pixels into a diffusion buffer instead of drawing a fresh one. 05 and 06 both recompute the full path every frame — 05 between two fixed electrodes, 06 chasing a moving pointer — while 07 keeps the path fixed and instead reorders which targets get hit first. 08 spends its entire per-frame budget on one multiply against a decay constant, the same single-value cooling curve that runs a lava flow's heat grid one scene over. 09 drops the stem altogether and lets a schedule of radial pulses stand in for a strike no eye could resolve as a line anyway. Follow them in sequence and item 09 isn't really a fresh idea — it's 01's midpoint displacement with the segment itself deleted, leaving only the light that used to travel along it.

Why fractals draw lightning? — midpoint displacement and recursive branching

A midpoint pushed sideways is a fractal in one line

Every item on this page traces back to one operation: take a segment, find its midpoint, and push that midpoint sideways by a random amount proportional to the segment's own length. 01 calls it with ROUGH = 0.42 and DEPTH = 6, so the recursion runs six levels deep. The same rule run on a square instead of a line is the recursive step terrain generators use to fold a flat grid into mountains; run it on a line instead and the output reads as lightning rather than terrain, because both share a fractal path, not a domain-specific behavior.

Branching only needs a probability that decays with generation

02 doesn't add new math for its branches — every fork calls the identical displace() function 01 uses, just with a smaller amplitude. What makes it read as a tree and not noise is DECAY = 0.66: a branch born at generation gen displaces with ROUGH * Math.pow(DECAY, gen), so a fifth-generation twig moves about 12% as far as the trunk (0.66 to the fifth power is roughly 0.125). MAX_GEN = 5 caps how deep that can go before a branch stops splitting.

Recomputing beats tweening when the path itself is the point

Items 05 through 07 never store a path and animate its opacity — they throw the old path away and call the displacement function again next frame. 05 does it between two electrodes fixed by fit(), which is what makes the arc feel anchored even as JITTER = 9 levels of recursion make every frame's path different from the last. Anything that keyframes a fixed SVG path instead pays for that same look with a much larger set of hand-authored frames.

Diffusion is displacement's smoothing counterpart, not a different trick

04's diffuse() doesn't call displace() at all, yet it produces the same family of soft, uneven edges — averaging every pixel toward its four neighbors (SPREAD = 0.44) and cooling the result (COOL = 0.986) is a low-pass filter, and a low-pass filter is what turns a hard fractal edge into the kind of glow a flat image only fakes with a pre-blurred layer.

Code per item

Every function under a heading below is pulled straight out of that item's own script tag rather than paraphrased into pseudocode, keeping the same constant names and the same order of operations as the live version — paste it into a project and it behaves exactly like the demo above it.

01The basic strike — one layer of midpoint displacement halving a segment

Halve the straight line between the two endpoints and nudge each midpoint sideways by a random amount, recursively, and the straight line turns into a jagged lightning stem. It's a momentary strike well suited to a hero background or a flash on a loading screen.

The basic strike — one layer of midpoint displacement halving a segment
Open in a new tab

01 · The basic strike — one layer of midpoint displacement halving a segment — Measured area 37.683% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

moveTolineTorequestAnimationFrame

Six levels of displacement turn a line into a stem

var DEPTH = 6;
var ROUGH = 0.42;

function displace(ax, ay, bx, by, depth, out) {
  if (depth === 0) { out.push([bx, by]); return; }
  var mx = (ax + bx) / 2, my = (ay + by) / 2;
  var dx = bx - ax, dy = by - ay;
  var len = Math.hypot(dx, dy) || 1;
  var nx = -dy / len, ny = dx / len;
  var off = (rand() * 2 - 1) * len * ROUGH * 0.5;
  mx += nx * off; my += ny * off;
  displace(ax, ay, mx, my, depth - 1, out);
  displace(mx, my, bx, by, depth - 1, out);
}
function makePath(sx, ex) {
  var pts = [[sx, -8]];
  displace(sx, -8, ex, H + 6, DEPTH, pts);
  return pts;
}

02Recursive branching — side forks thinning and dimming with depth

At each segment split by displacement, a low-probability new branch is drawn with its own displacement, its thickness and brightness scaled down by recursion depth so it never reads as the main stem. That decay rule is what keeps a game skill effect or an ornate banner strike from turning into visual noise.

Recursive branching — side forks thinning and dimming with depth
Open in a new tab

02 · Recursive branching — side forks thinning and dimming with depth — Measured area 31.275% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

lineToglobalCompositeOperationrequestAnimationFrame

Branch probability decays with generation depth

function grow(ax, ay, bx, by, gen, angle, depth, segments) {
  var pts = [[ax, ay]];
  displace(ax, ay, bx, by, depth, ROUGH * Math.pow(DECAY, gen), pts);
  segments.push({ pts: pts, gen: gen });
  if (gen >= MAX_GEN) return;
  var baseA = Math.atan2(by - ay, bx - ax);
  var k, idx, ba, bl, p;
  for (k = 1; k <= 5; k++) {
    idx = Math.floor(pts.length * k / 6);
    if (idx < 1 || idx >= pts.length) continue;
    if (rand() < BRANCH_P * (1.15 - gen / (MAX_GEN + 1))) {
      p = pts[idx];
      ba = baseA + (rand() < 0.5 ? -1 : 1) * (0.35 + rand() * 0.6);
      bl = Math.hypot(bx - ax, by - ay) * (0.5 + rand() * 0.28);
      grow(p[0], p[1], p[0] + Math.cos(ba) * bl, p[1] + Math.sin(ba) * bl, gen + 1, ba, depth - 1, segments);
    }
  }
}

03Storm sky — several strikes overlapping at random intervals

Sampling each bolt's wait time from an exponential distribution reproduces the real, uneven rhythm of clustered strikes followed by long lulls across several points on the canvas. It reads well as a background scene for a dark-mode hero or a page-transition loading flourish.

Storm sky — several strikes overlapping at random intervals
Open in a new tab

03 · Storm sky — several strikes overlapping at random intervals — Measured area 52.833% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

lineToglobalCompositeOperationrequestAnimationFrame

An exponential wait reproduces storm clustering

var MEAN = 0.5;

function stormLevel(t) {
  return 0.5 + STORM_A * Math.sin(t * 0.5) * Math.sin(t * 0.23 + 1.7);
}

var storm = stormLevel(t);
if (t >= nextAt) {
  spawn(t, storm);
  var mean = MEAN * (1.65 - storm * 1.1);
  nextAt = t + -Math.log(1 - rand()) * mean;
}

04Afterglow diffusion — the bolt's halo bleeding through a pixel buffer

Blending each pixel toward its neighbors' average brightness every frame, then writing it back, leaves a soft halo that lingers and cools even after the stroke itself is gone. That lingering glow works as an emphasis background on a product page or a faint glowing edge on a dark card, closer to a slow-breathing wash than the flat, single-moment look of a lightning effect png.

Afterglow diffusion — the bolt's halo bleeding through a pixel buffer
Open in a new tab

04 · Afterglow diffusion — the bolt's halo bleeding through a pixel buffer — Measured area 33.303% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ImageDataputImageDatarequestAnimationFrame

Four-neighbor averaging writes a cooling halo back

function diffuse() {
  for (var y = 0; y < H; y++) {
    var row = y * W;
    var up = (y > 0 ? y - 1 : y) * W;
    var dn = (y < H - 1 ? y + 1 : y) * W;
    for (var x = 0; x < W; x++) {
      var i = row + x;
      var l = lum[i];
      var av = (lum[row + (x > 0 ? x - 1 : x)] + lum[row + (x < W - 1 ? x + 1 : x)] +
                lum[up + x] + lum[dn + x]) * 0.25;
      lum2[i] = (l + SPREAD * (av - l)) * COOL;
    }
  }
  var tmp = lum; lum = lum2; lum2 = tmp;
}

05Tesla arc — a sustained discharge redrawn between two electrodes every frame

Recomputing the displaced path between two fixed points with a fresh random seed every frame keeps the endpoints still while the path itself trembles continuously, reading as a sustained arc. It suits a decorative element on a sci-fi themed site or a boss-pattern game background.

Tesla arc — a sustained discharge redrawn between two electrodes every frame
Open in a new tab

05 · Tesla arc — a sustained discharge redrawn between two electrodes every frame — Measured area 32.122% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

lineToglobalCompositeOperationrequestAnimationFrame

A fresh random seed every frame keeps the arc trembling

function genBolt(ax, ay, bx, by, mag, depth) {
  var pts = [{ x: ax, y: ay }, { x: bx, y: by }];
  var dx = bx - ax, dy = by - ay;
  var len = Math.hypot(dx, dy) || 1;
  var nx = -dy / len, ny = dx / len;
  var off = mag;
  for (var d = 0; d < depth; d++) {
    var next = [pts[0]];
    for (var i = 0; i < pts.length - 1; i++) {
      var p = pts[i], q = pts[i + 1];
      var r = (rand() * 2 - 1) * off;
      next.push({ x: (p.x + q.x) * 0.5 + nx * r, y: (p.y + q.y) * 0.5 + ny * r }, q);
    }
    pts = next;
    off *= 0.55;
  }
  return pts;
}

06Pointer-tracking strike — the path redrawn toward the cursor

Re-splitting the segment from a fixed origin to the pointer's coordinates with fresh displacement every frame makes the bolt feel like it chases and grips the cursor as it moves. It works as a mouse trail on an interactive hero or a badge hover effect.

Pointer-tracking strike — the path redrawn toward the cursor
Open in a new tab

06 · Pointer-tracking strike — the path redrawn toward the cursor — Measured area 31.824% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

pointermovelineTorequestAnimationFrame

Lerp chases the pointer, Lissajous fills in when there is none

if (!hasPointer) {
  var a = tSec * AUTOSPD;
  ptrX = W * 0.5 + Math.sin(a * 1.3 - 1.1) * W * 0.40;
  ptrY = H * 0.55 + Math.sin(a * 2.6) * H * 0.36;
}
curX += (ptrX - curX) * 0.22;
curY += (ptrY - curY) * 0.22;
if (!hasPointer) {
  origin.x = W * 0.5 + Math.sin(tSec * AUTOSPD * 0.55 - 0.6) * W * 0.32;
}

var main = genBolt(origin.x, origin.y, curX, curY, MAG_PX, 6);

07Chain lightning — hopping from one target point to the next

Sorting targets by proximity and drawing each displaced segment in order with a slightly increasing delay makes the strike look like it hops from one target to the next. It fits a game UI skill preview or an emphasis effect linking several cards.

Chain lightning — hopping from one target point to the next
Open in a new tab

07 · Chain lightning — hopping from one target point to the next — Measured area 52.858% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

lineToglobalCompositeOperationrequestAnimationFrame

Nearest-neighbor ordering turns points into a chain

function buildLayout() {
  nodes = FRACS.map(function (f) { return { x: f[0] * W, y: f[1] * H }; });
  var n = nodes.length, visited = [true];
  for (var i = 1; i < n; i++) visited.push(false);
  order = [0];
  for (var k = 1; k < n; k++) {
    var last = nodes[order[order.length - 1]];
    var bestI = -1, bestD = Infinity;
    for (var i2 = 0; i2 < n; i2++) {
      if (visited[i2]) continue;
      var dx = nodes[i2].x - last.x, dy = nodes[i2].y - last.y;
      var d2 = dx * dx + dy * dy;
      if (d2 < bestD) { bestD = d2; bestI = i2; }
    }
    order.push(bestI); visited[bestI] = true;
  }
  edges = [];
  for (var e = 0; e < order.length - 1; e++) edges.push([order[e], order[e + 1]]);
}

08Strike flash — the whole scene cools off exponentially right after impact

Setting a white overlay's opacity to its peak on the strike frame, then multiplying it by a decay factor every subsequent frame, produces a camera-flash-like brightening that fades exponentially. That single multiply is what carries a full-screen intro emphasizing the strike moment or a strong attention cue on a notification banner.

Strike flash — the whole scene cools off exponentially right after impact
Open in a new tab

08 · Strike flash — the whole scene cools off exponentially right after impact — Measured area 52.849% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

fillRectglobalCompositeOperationrequestAnimationFrame

One multiply per frame is the entire decay curve

var DECAY = 0.95;

flash *= Math.pow(DECAY, dt * 60);
if (t < 120) flash = 1;

function boltAlpha(t) {
  if (t < 0 || t > 320) return 0;
  if (t < 120) return 1;
  return 1 - (t - 120) / 200;
}

09Glow inside the cloud — a stemless cloud-to-cloud flicker in the haze

Instead of drawing a bolt, flashing a radial-gradient light source briefly at random points inside the cloud layer several times over reproduces the soft flicker of stemless intra-cloud discharge. It reads as atmospheric flair on a stormcloud background or a storm-state animation for a weather widget — closer to ambient lightning effect lights than a comic-book bolt.

Glow inside the cloud — a stemless cloud-to-cloud flicker in the haze
Open in a new tab

09 · Glow inside the cloud — a stemless cloud-to-cloud flicker in the haze — Measured area 45.795% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

createRadialGradientglobalCompositeOperationrequestAnimationFrame

A schedule of pulses replaces the stem entirely

var PULSE_COUNT = 9;
var CYCLE_MS = 500;

function buildSchedule() {
  prngState = SEED >>> 0;
  pulses = [];
  for (var i = 0; i < PULSE_COUNT; i++) {
    pulses.push({
      t0: i * (CYCLE_MS / PULSE_COUNT) + rand() * 18,
      dur: 110 + rand() * 120,
      x: (0.10 + rand() * 0.80) * W,
      y: (0.16 + rand() * 0.62) * CLOUD_H,
      col: GLOWS[i % GLOWS.length]
    });
  }
}

function pulseAlpha(p, t) {
  var u = t - p.t0;
  if (u < 0 || u > p.dur) return 0;
  if (u < p.dur * 0.2) return u / (p.dur * 0.2);
  var k = 1 - (u - p.dur * 0.2) / (p.dur * 0.8);
  return k * k;
}

Where a lightning effect actually earns its place

A hit reaction in a game UI or a button-click response fits 06's pointer-tracking strike, since the path redraws toward whatever coordinate the input lands on. 08's strike flash — the whole scene cooling exponentially right after impact — ports straight over as a transition effect between two views.

For background staging, 03's storm sky layers several overlapping strikes and 09's glow inside the cloud flickers quietly instead; either one belongs only in a hero area large enough to own the whole frame.

Where the bolt breaks once recursion depth runs too deep

A displacement ratio above 0.5 folds the stem back on itself

01's ROUGH = 0.42 keeps the sideways push under half the segment's own length, which is what guarantees the recursive path never crosses itself even after six levels of halving. Push that ratio past roughly 0.5 and a single unlucky sample can shove a midpoint further sideways than the segment is long, and the stem folds into a loop that reads as a knot instead of a fork.

A branch probability without generation decay never stops

02's per-generation term, 1.15 - gen / (MAX_GEN + 1), is what keeps BRANCH_P = 0.15 from spawning a new branch off of every branch forever. Drop that decay term and each of a branch's own five branch-candidate points can itself branch at the same flat 15%, and five generations deep the segment count grows fast enough to stall a frame.

An exponential mean that ignores storm level clusters unevenly

03's wait sampling, nextAt = t + -Math.log(1 - rand()) * mean, gy22a2jj only clusters convincingly because mean itself shrinks during a storm peak (MEAN * (1.65 - storm * 1.1)). Remove the storm term and every wait is drawn from the same flat average, and what should read as a squall gives you strikes spaced with no relation to how the sky is actually pulsing above it.

A cooling constant near 1 never lets the halo die

04's COOL = 0.986 multiplies every pixel's brightness down a little each frame on top of the diffusion average. Push it toward 1.0 and the halo from a strike two seconds ago is still visible when the next one lands, so the canvas reads as permanently lit fog instead of individual flashes cooling in sequence.

Three branch-count variants and their reduced-motion fallback

One constant, three different lightning bolts

Variant Changed value What it feels like
Branch probability 02 recursive branching 0.15 → 0.4 Branches multiply until the strike reads as tree-dense
Displacement strength 01 basic strike displacement width 20px → 60px The stem turns rougher and snaps at sharper angles
Strike frequency 03 storm sky average wait 900ms → 2400ms The storming sky quiets down and the tension stretches out

Nothing here is drawn by hand, so widening one number reshapes the whole storm instead of asking someone to redraw a lightning effect background image frame by frame.

What reduced-motion keeps

A single matchMedia('(prefers-reduced-motion: reduce)') check on load decides whether requestAnimationFrame ever gets called at all — when it comes back true, each of the nine items draws one fixed frame instead (STILL_T picks the moment for the strike-based scenes, a set schedule tick does the same job for 09's pulse layer) and stops there. A visitor who asked for less motion still gets a lit scene, just a frozen one, never an empty canvas.

Pointer input never blocks a keyboard visitor

06's pointer-tracking strike reads coordinates from pointermove on the field element, which also carries tabindex="0" so a keyboard user landing on the demo isn't stuck behind a mouse-only interaction — without a pointer, hasPointer stays false and the Lissajous fallback keeps the bolt moving on its own. The other eight items read no pointer or keyboard input at all, since their motion comes entirely from a clock rather than an input device.

Questions people ask before wiring up a lightning strike

How is a canvas lightning effect different from a lightning effect video overlay

A lightning effect video overlay is a fixed clip — the same handful of strikes looping on a timer, however it's composited. Every scene on this page recomputes its path from a random seed each time it fires, so no two strikes across the nine items are ever pixel-identical, and the whole thing is vector strokes on a canvas element rather than decoded video frames.

Can I paste this lightning effect background code in and use it as-is

Yes — every code block above is copied from the running demo's actual source, not retyped, and each item's full file (HTML, SCSS, and JS) sits inside the zip behind the password on this page. Nothing in this set of nine reaches out to an external library or CDN, so its canvas markup and script tag can be lifted into any page and it renders the same way it does here.

Why does item 03's storm measure 20 of 24 sampled frames as moved instead of 23 like the rest

All nine items render 24 sampled frames, and every other item measures 23 of them as changed from the previous frame. Item 03 measures only 20 because its wait between strikes is drawn from an exponential distribution, and in the lull stretches that distribution occasionally produces, a few consecutive sampled frames land with no new strike and no sky-glow left decaying — the same clustering that makes a storm look real also lets it sit still for a sampled frame or two.

Sources: MDN — CanvasRenderingContext2D · MDN — CanvasRenderingContext2D.globalCompositeOperation · MDN — Window.requestAnimationFrame() · MDN — prefers-reduced-motion · MDN — ImageData · the diamond-square algorithm that the same recursive midpoint-displacement rule generalizes to, one dimension up.

Enter the archive password

The password is inside this article. You will find it as you read.