Copied!
Background

Lightning bolts

Branching lightning bolts drawn by midpoint displacement strike from the top towards the pointer on click or on a timer, with a calm mode and a restrained flash.

← Library

Element · Vanilla JS + Canvas 2D + CSSUpdated November 6, 2026

BackgroundElementVanilla JS + Canvas 2D + CSS

Live preview needs JavaScript. The code below works without any dependencies.

Prompt for your AI assistant

Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.

Build "Lightning bolts" for my site using the Enrichr Library element at https://enrichr.dev/elements/lightning-bolts.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/lightning-bolts.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.

Settings:
- accent: #e6ff55
- background: #0c1e18
- mode: calm
- interval: 2.5
- branches: 3

Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.

The same spec as JSON, for tools that read it directly: /elements/lightning-bolts.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-lbt">
  <div class="ef-lbt__wrap">
    <canvas class="ef-lbt__cv" aria-hidden="true"></canvas>
    <svg class="ef-lbt__piece ef-lbt__piece--a" aria-hidden="true" focusable="false"><symbol id="ef-lbt-p4" viewBox="1423.36 860.8 503.07 322.3"><path fill="currentColor" d="M1821 875.8v111.6c0 8 6.5 14.5 14.5 14.5 4.2 0 8.1-1.9 10.9-5 6.8-7.5 16.7-12.3 27.7-12.3 22 0 39.6 19 37.1 41.6-1.9 16.7-15.2 30.5-32 32.7-13 1.7-24.8-3.3-32.7-11.9-2.8-3.2-6.8-5-11-5-8 0-14.5 6.4-14.5 14.4v111.7h-111.6c-8 0-14.5-6.5-14.5-14.5 0-4.2 1.9-8.2 5-11 8.7-7.9 13.7-19.7 12-32.7-2.3-16.8-16-30.1-32.8-32-22.5-2.5-41.5 15.1-41.5 37.1 0 11 4.7 20.9 12.3 27.7 3.1 2.8 4.9 6.7 4.9 10.8v0.1c0 8-6.4 14.5-14.5 14.5h-111.6v-111.7c0-8-6.4-14.4-14.5-14.4-4.1 0-8 1.8-10.8 4.9-6.9 7.6-16.7 12.3-27.7 12.3-22 0-39.6-19-37.1-41.6 1.9-16.7 15.2-30.4 32-32.7 13-1.7 24.8 3.3 32.7 11.9 2.8 3.2 6.7 5.1 10.9 5.1 8.1 0 14.5-6.5 14.5-14.5v-111.6z"/></symbol><use href="#ef-lbt-p4" width="100%" height="100%"/></svg>
    <svg class="ef-lbt__piece ef-lbt__piece--b" aria-hidden="true" focusable="false"><symbol id="ef-lbt-p8" viewBox="1513.7 166.86 322.3 503.06"><path fill="currentColor" d="M1528.7 564.5v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5 7.8 8.7 19.7 13.7 32.7 11.9 16.8-2.2 30.1-15.9 32-32.7 2.5-22.5-15.1-41.5-37.1-41.5-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.5-14.5-14.5v-111.6h111.6c8.1 0 14.5-6.5 14.5-14.5 0-4.1-1.8-8.1-4.9-10.9-7.6-6.8-12.3-16.6-12.3-27.6 0-22 19-39.6 41.5-37.1 16.8 1.8 30.5 15.2 32.8 32 1.7 12.9-3.3 24.8-12 32.7-3.1 2.8-5 6.7-5 10.9 0 8 6.5 14.5 14.5 14.5h111.6v111.6c0 8-6.4 14.5-14.5 14.5-4.2 0-8.1-1.9-10.9-5-7.9-8.7-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.5 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5v111.6h-111.6c-8 0-14.5 6.5-14.5 14.5 0 4.2 1.9 8.1 5 10.9 7.5 6.8 12.3 16.7 12.3 27.7 0 22-19 39.5-41.6 37.1-16.8-1.9-30.5-15.3-32.7-32-1.7-13 3.3-24.9 11.9-32.7 3.1-2.9 5-6.8 5-11 0-8-6.4-14.5-14.5-14.5z"/></symbol><use href="#ef-lbt-p8" width="100%" height="100%"/></svg>
    <svg class="ef-lbt__piece ef-lbt__piece--c" aria-hidden="true" focusable="false"><symbol id="ef-lbt-p1" viewBox="1513.7 1453.9 322.3 322.3"><path fill="currentColor" d="M1821 1761.2h-292.3v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5.1 7.8 8.6 19.7 13.6 32.7 11.9 16.8-2.3 30.1-16 32-32.7 2.5-22.6-15.1-41.6-37.1-41.6-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.4-14.5-14.4v-111.7h292.3v111.7c0 8-6.4 14.4-14.5 14.4-4.2 0-8.1-1.8-10.9-5-7.9-8.6-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.6 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5z"/></symbol><use href="#ef-lbt-p1" width="100%" height="100%"/></svg>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-lbt { width: 100%; }
.ef-lbt__wrap { position: relative; box-sizing: border-box; width: 100%; min-height: 18rem; height: 100%; overflow: hidden; border-radius: 1rem; background: #0c1e18; touch-action: pan-y; }
.ef-lbt__cv { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.ef-lbt__piece { position: absolute; pointer-events: none; color: var(--ef-acc, #e6ff55); filter: drop-shadow(0 6px 10px rgba(0, 0, 0, 0.45)); animation: ef-lbt-float 7s ease-in-out infinite; }
.ef-lbt__piece--a { width: 7.5rem; height: 4.8rem; top: 7%; right: 6%; transform: rotate(8deg); }
.ef-lbt__piece--b { width: 3.6rem; height: 5.6rem; bottom: 8%; left: 6%; color: #c6f26b; opacity: 0.92; animation-delay: -2.4s; }
.ef-lbt__piece--c { width: 3rem; height: 3rem; bottom: 14%; right: 22%; color: #fbf8f1; opacity: 0.85; animation-delay: -4.6s; }
@keyframes ef-lbt-float { 0%, 100% { translate: 0 0; rotate: 0deg; } 50% { translate: 0 -10px; rotate: 5deg; } }
@media (prefers-reduced-motion: reduce) { .ef-lbt__piece { animation: none; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Lightning bolts: procedural bolts built by midpoint displacement with side branches, drawn as a soft glow plus a bright core.
// Strikes fall from the top towards the pointer on click and on a timer. Photosensitivity: strikes are rate-limited to under 3 a second,
// the flash is a faint wash, and "calm" mode removes the flash and flicker entirely. Reduced motion draws one still bolt.
// mount(root, { background, accent, mode ("calm"|"lively"), interval (s), branches (0-6) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-lbt__wrap");
  var cv = root.querySelector(".ef-lbt__cv");
  var ctx = cv.getContext("2d");
  var opts = {}, offs = [], bolts = [];
  var w = 0, h = 0, dpr = 1, px = 0, py = 0, hasPtr = false;
  var raf = 0, vis = true, dead = false, nextAuto = 0, lastStrike = -1e9, flashAt = -1e9;
  var MIN_GAP = 380; // ms between strikes: under 3 per second

  function rnd(a, b) { return a + Math.random() * (b - a); }
  function path(x1, y1, x2, y2, disp, iters) {
    var pts = [[x1, y1], [x2, y2]];
    for (var it = 0; it < iters; it++) {
      var out = [pts[0]];
      for (var i = 1; i < pts.length; i++) {
        var a = pts[i - 1], b = pts[i];
        var dx = b[0] - a[0], dy = b[1] - a[1], len = Math.sqrt(dx * dx + dy * dy) || 1;
        var off = rnd(-1, 1) * disp;
        out.push([(a[0] + b[0]) / 2 - dy / len * off, (a[1] + b[1]) / 2 + dx / len * off]);
        out.push(b);
      }
      pts = out; disp *= 0.55;
    }
    return pts;
  }
  function makeBolt(tx, ty, auto) {
    var sx = Math.max(10, Math.min(w - 10, tx + rnd(-0.22, 0.22) * w)), sy = -6;
    var main = path(sx, sy, tx, ty, Math.hypot(tx - sx, ty - sy) * 0.22, 7);
    var lines = [{ pts: main, wd: 2.2, al: 1 }];
    var nb = Math.round(opts.branches);
    for (var b = 0; b < nb; b++) {
      var k = Math.floor(rnd(0.12, 0.85) * main.length), p = main[k];
      var ang = Math.atan2(ty - sy, tx - sx) + rnd(0.35, 0.9) * (Math.random() < 0.5 ? -1 : 1);
      var L = rnd(0.12, 0.3) * Math.hypot(tx - sx, ty - sy);
      var ex = p[0] + Math.cos(ang) * L, ey = p[1] + Math.sin(ang) * L;
      var br = path(p[0], p[1], ex, ey, L * 0.2, 5);
      lines.push({ pts: br, wd: 1.2, al: 0.7 });
      if (Math.random() < 0.4) {
        var q = br[Math.floor(br.length * 0.5)], a2 = ang + rnd(-0.6, 0.6), L2 = L * 0.5;
        lines.push({ pts: path(q[0], q[1], q[0] + Math.cos(a2) * L2, q[1] + Math.sin(a2) * L2, L2 * 0.2, 4), wd: 0.8, al: 0.45 });
      }
    }
    return { lines: lines, t0: performance.now(), dur: opts.mode === "calm" ? 1300 : 800, seed: Math.random() * 6 };
  }
  function strike(tx, ty, now) {
    if (now - lastStrike < MIN_GAP) return;
    lastStrike = now; flashAt = now;
    bolts.push(makeBolt(tx, ty, false));
    if (bolts.length > 3) bolts.shift();
    kick();
  }
  function stroke(b, env, calm) {
    var i, j, L, p;
    ctx.lineCap = "round"; ctx.lineJoin = "round";
    for (i = 0; i < 2; i++) {
      ctx.save();
      if (i === 0) { ctx.shadowColor = opts.accent; ctx.shadowBlur = calm ? 14 : 22; ctx.strokeStyle = opts.accent; }
      else ctx.strokeStyle = "#fbf8f1";
      for (j = 0; j < b.lines.length; j++) {
        L = b.lines[j]; p = L.pts;
        ctx.globalAlpha = Math.min(1, env * L.al * (i === 0 ? (calm ? 0.45 : 0.6) : 1));
        ctx.lineWidth = L.wd * (i === 0 ? 3.2 : 1) * (calm ? 0.85 : 1);
        ctx.beginPath(); ctx.moveTo(p[0][0], p[0][1]);
        for (var k = 1; k < p.length; k++) ctx.lineTo(p[k][0], p[k][1]);
        ctx.stroke();
      }
      ctx.restore();
    }
  }
  function draw(now) {
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.clearRect(0, 0, w, h);
    var calm = opts.mode === "calm";
    if (!calm) {
      var f = Math.exp(-(now - flashAt) / 140) * 0.1;
      if (f > 0.004) { ctx.globalAlpha = f; ctx.fillStyle = opts.accent; ctx.fillRect(0, 0, w, h); ctx.globalAlpha = 1; }
    }
    for (var i = bolts.length - 1; i >= 0; i--) {
      var b = bolts[i], u = (now - b.t0) / b.dur;
      if (u >= 1) { bolts.splice(i, 1); continue; }
      var env = Math.min(1, u / 0.06) * Math.pow(1 - u, 1.6);
      if (!calm) env *= 1 - 0.22 * (0.5 + 0.5 * Math.sin(u * 16 + b.seed)); // slow shimmer, never a strobe
      stroke(b, env, calm);
    }
  }
  function frame(now) {
    raf = 0;
    if (dead || !vis) return;
    if (now >= nextAuto) {
      var tx = hasPtr ? px : rnd(0.2, 0.8) * w, ty = hasPtr ? py : rnd(0.55, 0.95) * h;
      strike(tx + rnd(-30, 30), ty, now);
      var iv = Math.max(1.2, opts.interval) * (opts.mode === "calm" ? 1.7 : 1) * 1000;
      nextAuto = now + iv * rnd(0.7, 1.3);
    }
    draw(now);
    raf = requestAnimationFrame(frame);
  }
  function kick() { if (!reduce && !raf && vis && !dead) raf = requestAnimationFrame(frame); }
  function still() {
    bolts = [makeBolt(w * 0.58, h * 0.78, false)];
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, w, h);
    stroke(bolts[0], 0.8, true);
  }
  function build() {
    var r = wrap.getBoundingClientRect();
    w = Math.max(40, r.width); h = Math.max(40, r.height);
    dpr = Math.min(2, window.devicePixelRatio || 1);
    cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr);
  }
  function on(el, ev, fn) { el.addEventListener(ev, fn); offs.push(function () { el.removeEventListener(ev, fn); }); }
  on(wrap, "pointermove", function (e) { var r = wrap.getBoundingClientRect(); px = e.clientX - r.left; py = e.clientY - r.top; hasPtr = true; });
  on(wrap, "pointerleave", function () { hasPtr = false; });
  on(wrap, "pointerdown", function (e) {
    var r = wrap.getBoundingClientRect();
    px = e.clientX - r.left; py = e.clientY - r.top;
    if (reduce) { still(); return; }
    strike(px, py, performance.now());
  });
  var ro = new ResizeObserver(function () { if (dead) return; build(); if (reduce) still(); });
  ro.observe(wrap);
  offs.push(function () { ro.disconnect(); });
  var io = new IntersectionObserver(function (es) { vis = es[0].isIntersecting; if (vis) kick(); else if (raf) { cancelAnimationFrame(raf); raf = 0; } });
  io.observe(wrap);
  offs.push(function () { io.disconnect(); });

  function update(next) {
    opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", mode: "calm", interval: 2.5, branches: 3 }, opts, next || {});
    wrap.style.background = "radial-gradient(ellipse 80% 55% at 50% 0%, #005236 0%, transparent 70%), " + opts.background;
    wrap.style.setProperty("--ef-acc", opts.accent);
    if (!w) build();
    if (reduce) still(); else { if (!nextAuto) nextAuto = performance.now() + 500; kick(); }
  }
  update(o);
  function destroy() {
    dead = true;
    if (raf) cancelAnimationFrame(raf);
    raf = 0; bolts = [];
    offs.forEach(function (f) { f(); }); offs = [];
  }
  return { update: update, destroy: destroy };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","background":"#0c1e18","mode":"calm","interval":2.5,"branches":3});

How it works

A bolt starts as a straight line from just above the top edge to the target. Midpoint displacement then splits every segment in two and nudges the new midpoint sideways by a random amount that halves each pass, giving a jagged natural path. Side branches start from random points on the main path, head off at an angle and are built the same way with less detail. Each bolt is drawn twice, a wide soft glow in the accent colour using canvas shadow blur, then a thin cream core, and fades out with a fast rise and a power-curve decay plus a slow shimmer. Strikes come from a timer, aimed near the pointer, or from a click or tap at that point. This is an original build from the general idea, not a copy of any library.

Accessibility and performance

Designed for photosensitivity. Strikes are limited to one every 380 ms at most (under 3 a second), the flash is a faint accent wash at 10 percent opacity or less, and shimmer is a slow sine, not a strobe. Calm mode, the default, removes the flash and the shimmer and slows everything. With prefers-reduced-motion the loop stops and one still bolt is drawn; a click redraws a still bolt. The canvas is aria-hidden and decorative.

One canvas and one rAF loop that pauses off-screen. At most three bolts are alive at once, each a few hundred line segments; shadow blur is applied to the glow pass only. Device pixel ratio is capped at 2.

SectionBackground
TechniquesPointer interaction
StackVanilla JS, Canvas 2D, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
On CodePenOpen the live pen on our CodePen account; fork it to experiment.
Inspired byLightning (inspiration only, original rebuild) by React Bits on reactbits.dev. Original code, written from scratch.

Find this technique on a real site

Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.

Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns

Licence and provenance

Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.

This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.

Rule sweep listA line sweeps across a menu row on hover or focus and exits through the opposite side. Depth parallax cardLayered shapes shift by different amounts as the pointer moves, drifting on touch screens. 3D ring carouselCards on a CSS 3D ring that rotates one step at a time, with buttons, arrow keys and autoplay.

FAQ

Can I make it strike more often?

Lower the auto strike interval, but strikes are still capped at under 3 a second by design. Do not remove that cap on public pages.

What does calm mode change?

No background flash, no shimmer, thinner and softer bolts, a longer fade and rarer automatic strikes.

Can the bolts hit a specific element?

Yes. Pass the target coordinates to the strike function, or move the pointer there; auto strikes aim near the last pointer position.