← Generative art

Electric Arc

Jagged bolts of lightning crackle between two glowing electrodes, re-forming many times a second and spitting out short forks, and the whole box flickers with each strike. The far electrode wanders about by itself; move the pointer, or drag a finger, to steer it.

Move or drag to steer the arc

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-electric-arc to a box with a height and a dark background.

Options

Add any of these attributes to the same element.

data-colorGlow colour. Default #a78bfa.
data-boltsBolts at once. Default 2.
data-forksChance of a fork at each kink, 0 to 1. Default 0.12.
data-rateTime between new bolts. Default 70 ms.
data-pointer="false"Ignore the pointer.

Each bolt is made by midpoint displacement: split the line in half, push the middle sideways, and repeat with smaller pushes. Flashing light can bother some people, so with reduced motion it shows a single still bolt. It’s paused when off screen.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Electric Arc: jagged bolts of lightning crackle between two glowing
    // electrodes, re-forming many times a second and throwing off short
    // forks. The far end wanders about; move the pointer, or drag a finger,
    // to steer it.
    // Usage: <div data-electric-arc style="height:360px;background:#0a0618"></div>
    // Optional: data-color (glow colour, default #a78bfa), data-bolts (bolts
    // at once, default 2), data-forks (chance of a fork at each kink, 0 to 1,
    // default 0.12), data-rate (ms between new bolts, default 70),
    // data-pointer="false" (ignore the pointer).
    var DEFAULTS = { color: '#a78bfa', bolts: 2, forks: 0.12, rate: 70 };

    var css =
      '[data-electric-arc]{position:relative;isolation:isolate;overflow:hidden;touch-action:pan-y}' +
      '.eac-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function rgba(hex, a) {
      var c = hex.replace('#', '');
      if (c.length === 3) c = c[0] + c[0] + c[1] + c[1] + c[2] + c[2];
      var n = parseInt(c, 16);
      return 'rgba(' + (n >> 16 & 255) + ',' + (n >> 8 & 255) + ',' + (n & 255) + ',' + a + ')';
    }

    // A jagged line from a to b: split each segment at its middle and push
    // the middle sideways, less each time round.
    function jag(ax, ay, bx, by, rough, depth) {
      var pts = [ax, ay, bx, by];
      var off = Math.sqrt((bx - ax) * (bx - ax) + (by - ay) * (by - ay)) * rough;
      for (var k = 0; k < depth; k++) {
        var next = [pts[0], pts[1]];
        for (var i = 0; i < pts.length - 2; i += 2) {
          var x1 = pts[i], y1 = pts[i + 1], x2 = pts[i + 2], y2 = pts[i + 3];
          var dx = x2 - x1, dy = y2 - y1, len = Math.sqrt(dx * dx + dy * dy) || 1;
          var o = (Math.random() - 0.5) * off;
          next.push((x1 + x2) / 2 - dy / len * o, (y1 + y2) / 2 + dx / len * o, x2, y2);
        }
        pts = next;
        off *= 0.55;
      }
      return pts;
    }

    function build(el) {
      var d = el.dataset;
      var canvas = document.createElement('canvas');
      canvas.className = 'eac-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var color = d.color || DEFAULTS.color;
      var nBolts = +d.bolts || DEFAULTS.bolts;
      var forkChance = d.forks !== undefined ? +d.forks : DEFAULTS.forks;
      var rate = +d.rate || DEFAULTS.rate;
      var usePointer = d.pointer !== 'false';
      var w = 0, h = 0, dpr = 1, t = 0, pointer = null;
      var A = { x: 0, y: 0 }, B = { x: 0, y: 0 };
      var bolts = [], since = 0, flash = 0;

      function resize() {
        var nw = el.clientWidth, nh = el.clientHeight;
        if (nw === w && nh === h) return false;
        w = nw; h = nh;
        dpr = Math.min(2, window.devicePixelRatio || 1);
        canvas.width = Math.round(w * dpr);
        canvas.height = Math.round(h * dpr);
        A.x = Math.max(28, w * 0.16); A.y = h / 2;
        if (!B.x) { B.x = w * 0.82; B.y = h / 2; }
        return true;
      }

      // Where the far end wants to be: the pointer, or a slow wander.
      function target() {
        if (pointer) return pointer;
        return {
          x: w * (0.72 + Math.sin(t * 0.41) * 0.1),
          y: h * (0.5 + Math.sin(t * 0.67 + 1.3) * 0.28 + Math.sin(t * 1.9) * 0.04)
        };
      }

      function spawn() {
        var dx = B.x - A.x, dy = B.y - A.y, len = Math.sqrt(dx * dx + dy * dy);
        var depth = len > 400 ? 7 : len > 180 ? 6 : 5;
        var main = jag(A.x, A.y, B.x, B.y, 0.22, depth);
        var forks = [];
        for (var i = 4; i < main.length - 6; i += 2) {
          if (Math.random() > forkChance) continue;
          // A short fork that sets off roughly the way the bolt is going.
          var ang = Math.atan2(dy, dx) + (Math.random() - 0.5) * 2.2;
          var fl = len * (0.08 + Math.random() * 0.16);
          forks.push(jag(main[i], main[i + 1], main[i] + Math.cos(ang) * fl, main[i + 1] + Math.sin(ang) * fl, 0.3, 4));
        }
        bolts.push({ pts: main, forks: forks, life: 1 });
        if (bolts.length > nBolts + 2) bolts.shift();
        flash = Math.min(1, flash + 0.5);
      }

      function stroke(pts) {
        ctx.beginPath();
        ctx.moveTo(pts[0], pts[1]);
        for (var i = 2; i < pts.length; i += 2) ctx.lineTo(pts[i], pts[i + 1]);
        ctx.stroke();
      }

      function electrode(p, r) {
        var g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r * 5);
        g.addColorStop(0, rgba('#ffffff', 0.9));
        g.addColorStop(0.18, rgba(color, 0.55 + flash * 0.3));
        g.addColorStop(1, rgba(color, 0));
        ctx.fillStyle = g;
        ctx.fillRect(p.x - r * 5, p.y - r * 5, r * 10, r * 10);
        ctx.fillStyle = '#f5f3ff';
        ctx.beginPath();
        ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
        ctx.fill();
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        // The whole box lights up faintly with each strike.
        var mx = (A.x + B.x) / 2, my = (A.y + B.y) / 2, big = Math.max(w, h) * 0.7;
        var glow = ctx.createRadialGradient(mx, my, 0, mx, my, big);
        glow.addColorStop(0, rgba(color, 0.08 + flash * 0.14));
        glow.addColorStop(1, rgba(color, 0));
        ctx.fillStyle = glow;
        ctx.fillRect(0, 0, w, h);
        ctx.globalCompositeOperation = 'lighter';
        ctx.lineCap = 'round';
        ctx.lineJoin = 'round';
        for (var i = 0; i < bolts.length; i++) {
          var b = bolts[i], a = b.life;
          if (a <= 0) continue;
          ctx.strokeStyle = rgba(color, 0.12 * a);
          ctx.lineWidth = 10;
          stroke(b.pts);
          ctx.strokeStyle = rgba(color, 0.45 * a);
          ctx.lineWidth = 4;
          stroke(b.pts);
          ctx.strokeStyle = 'rgba(255,255,255,' + (0.95 * a) + ')';
          ctx.lineWidth = 1.5;
          stroke(b.pts);
          for (var f = 0; f < b.forks.length; f++) {
            ctx.strokeStyle = rgba(color, 0.5 * a);
            ctx.lineWidth = 2.5;
            stroke(b.forks[f]);
            ctx.strokeStyle = 'rgba(255,255,255,' + (0.6 * a) + ')';
            ctx.lineWidth = 0.8;
            stroke(b.forks[f]);
          }
        }
        ctx.globalCompositeOperation = 'source-over';
        var r = Math.max(5, Math.min(w, h) * 0.025);
        electrode(A, r * 1.3);
        electrode(B, r);
      }

      function tick(dt) {
        t += dt / 1000;
        var tg = target();
        var k = pointer ? 0.3 : 0.06;
        B.x += (Math.max(A.x + 50, Math.min(w - 12, tg.x)) - B.x) * k;
        B.y += (Math.max(12, Math.min(h - 12, tg.y)) - B.y) * k;
        since += dt;
        if (since > rate * (0.6 + Math.random() * 0.8)) {
          since = 0;
          spawn();
          if (bolts.length < nBolts) spawn();
        }
        for (var i = 0; i < bolts.length; i++) bolts[i].life -= dt / (rate * nBolts * 1.6);
        flash = Math.max(0, flash - dt / 220);
      }

      resize();
      t = 1;
      B = target();
      for (var i = 0; i < nBolts; i++) spawn();
      draw();
      if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) draw(); }).observe(el);
      if (reduced()) return;

      if (usePointer) {
        var at = function (e) {
          var bx = el.getBoundingClientRect();
          pointer = { x: e.clientX - bx.left, y: e.clientY - bx.top };
        };
        el.addEventListener('pointermove', function (e) { if (e.pointerType === 'mouse' || e.buttons) at(e); });
        el.addEventListener('pointerdown', at);
        el.addEventListener('pointerleave', function () { pointer = null; });
        el.addEventListener('pointerup', function (e) { if (e.pointerType !== 'mouse') pointer = null; });
        el.addEventListener('pointercancel', function () { pointer = null; });
      }

      var last = 0;
      var timer = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = last ? Math.min(50, now - last) : 16;
          last = now;
          tick(dt);
          draw();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-electric-arc]').forEach(function (el) {
        if (el._eac) return;
        el._eac = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-electric-arc style="height: 360px; background: #0a0618"></div>