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
- Paste the snippet just before
</head>. - Add
data-electric-arcto a box with a height and a dark background.
Options
Add any of these attributes to the same element.
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 | Time 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.
<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><div data-electric-arc style="height: 360px; background: #0a0618"></div>