Lightning Storm
Dark clouds roll along the top of a section while rain lashes down, and every few seconds a forked bolt of lightning cracks down to the ground, flickering as it lights up the clouds and the whole sky.
Feel the power
How to use it
- Paste the snippet just before
</head>. - Add
data-lightning-stormto a section with a dark background.
Options
Add any of these attributes to the same element.
data-interval | Average time between strikes. Default 2600 ms. |
data-color | Colour of the lightning and its glow. Default pale blue #c7d2fe. |
data-clouds | Cloud colour. Default #3b3758. |
data-rain="false" | No rain. |
Every bolt is new: a random jagged path with a few branches. Now and then the flash stays hidden inside the clouds. It’s drawn on a transparent canvas behind your content and paused when off screen. With reduced motion there is no lightning, just the still clouds and rain, so nothing flashes.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Lightning Storm: dark clouds roll slowly across the top of a section
// while rain falls, and every few seconds a forked bolt of lightning
// strikes, lighting up the clouds and the whole sky with a flicker. Drawn
// on a transparent canvas behind your content, and paused when off screen.
// Usage: <section data-lightning-storm style="background:linear-gradient(#0f0c1d,#1e1b3a)">…</section>
// Optional: data-interval (average ms between strikes, default 2600),
// data-color (colour of the lightning, default pale blue #c7d2fe),
// data-clouds (cloud colour, default #3b3758), data-rain="false" (no rain).
var DEFAULTS = { interval: 2600, color: '#c7d2fe', clouds: '#3b3758' };
var css =
'[data-lightning-storm]{position:relative;isolation:isolate;overflow:hidden}' +
'.ltn-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1;pointer-events:none}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var TAU = Math.PI * 2;
var probe = document.createElement('canvas').getContext('2d');
function rgb(c) {
probe.fillStyle = '#000';
probe.fillStyle = c;
var v = probe.fillStyle;
if (v[0] === '#') return [parseInt(v.slice(1, 3), 16), parseInt(v.slice(3, 5), 16), parseInt(v.slice(5, 7), 16)];
return v.match(/[\d.]+/g).slice(0, 3).map(Number);
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
// One storm cloud, painted once on its own canvas: lumpy puffs, darker
// underneath.
function cloudSprite(ch, col, scale) {
var R = anime.utils.random;
var cw = ch * 2.4;
var c = document.createElement('canvas');
c.width = Math.ceil(cw * scale);
c.height = Math.ceil(ch * scale);
var g = c.getContext('2d');
g.scale(scale, scale);
var lit = [Math.min(255, col[0] + 28), Math.min(255, col[1] + 26), Math.min(255, col[2] + 34)];
var dark = [col[0] * 0.55, col[1] * 0.55, col[2] * 0.6].map(Math.round);
for (var i = 0; i < 26; i++) {
var r = ch * R(0.1, 0.24, 3);
var x = R(r, cw - r, 1), y = R(ch * 0.2 + r * 0.5, ch - r, 1);
var gr = g.createRadialGradient(x, y, 0, x, y, r);
gr.addColorStop(0, rgba(i % 3 ? col : lit, 0.95));
gr.addColorStop(0.65, rgba(col, 0.8));
gr.addColorStop(1, rgba(col, 0));
g.fillStyle = gr;
g.beginPath();
g.arc(x, y, r, 0, TAU);
g.fill();
}
g.globalCompositeOperation = 'source-atop';
var sh = g.createLinearGradient(0, 0, 0, ch);
sh.addColorStop(0.3, rgba(dark, 0));
sh.addColorStop(1, rgba(dark, 0.85));
g.fillStyle = sh;
g.fillRect(0, 0, cw, ch);
return { img: c, w: cw, h: ch };
}
// A jagged line from a to b: split each segment in two and nudge the
// middle sideways, again and again, with smaller nudges each time.
function jag(x1, y1, x2, y2, disp, iter) {
var pts = [[x1, y1], [x2, y2]];
for (var k = 0; k < iter; k++) {
var np = [pts[0]];
for (var i = 0; i < pts.length - 1; i++) {
var a = pts[i], b = pts[i + 1];
var dx = b[0] - a[0], dy = b[1] - a[1], len = Math.hypot(dx, dy) || 1;
var off = (Math.random() * 2 - 1) * disp;
np.push([(a[0] + b[0]) / 2 - (dy / len) * off, (a[1] + b[1]) / 2 + (dx / len) * off], b);
}
pts = np;
disp *= 0.55;
}
return pts;
}
function build(el) {
var d = el.dataset;
var bolt = rgb(d.color || DEFAULTS.color);
var cloudCol = rgb(d.clouds || DEFAULTS.clouds);
var cloudDark = cloudCol.map(function (v) { return Math.round(v * 0.7); });
var interval = +d.interval || DEFAULTS.interval;
var rainy = d.rain !== 'false';
var canvas = document.createElement('canvas');
canvas.className = 'ltn-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var R = anime.utils.random;
var w = 0, h = 0, dpr = 1, clouds = [], rain = [];
var strike = null, next = 700, clock = 0;
var still = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function resize() {
var nw = el.clientWidth, nh = el.clientHeight;
var nd = Math.min(2, window.devicePixelRatio || 1);
if (nw === w && nh === h && nd === dpr && clouds.length) return;
w = nw; h = nh; dpr = nd;
canvas.width = w * dpr;
canvas.height = h * dpr;
// A thick bank of cloud along the top, two layers deep.
clouds = [];
var ch = Math.max(70, Math.min(h * 0.5, w * 0.35));
[0, 1].forEach(function (layer) {
var size = ch * (layer ? 1 : 0.8);
var n = Math.ceil(w / (size * 1.5)) + 2;
for (var i = 0; i < n; i++) {
var s = cloudSprite(size * R(0.85, 1.15, 2), cloudCol, dpr);
clouds.push({ s: s, x: (i - 1) * size * 1.5 + R(-20, 20), y: -s.h * (layer ? 0.42 : 0.55) + R(-10, 10) + layer * h * 0.04, v: layer ? 9 : 5, span: n * size * 1.5 });
}
});
var n = Math.round(Math.min(320, (w * h) / 2600));
rain = [];
for (var j = 0; j < n; j++) rain.push({ x: R(0, w, 1), y: R(0, h, 1), l: R(8, 18, 1), v: R(500, 800) });
draw();
}
// Each strike flickers: a few bright pulses that quickly die away.
function envelope(age) {
if (!strike) return 0;
var e = 0;
for (var i = 0; i < strike.pulses.length; i++) {
var p = strike.pulses[i];
if (age >= p[0]) e = Math.max(e, p[1] * Math.exp(-(age - p[0]) / 110));
}
return e;
}
function newStrike() {
var x0 = R(0.12, 0.88, 3) * w;
var y0 = h * R(0.06, 0.16, 3);
var x1 = x0 + R(-0.22, 0.22, 3) * w;
var main = jag(x0, y0, x1, h * R(0.85, 1.05, 3), h * 0.16, 6);
var paths = [{ pts: main, wd: 1 }];
var branches = R(2, 4);
for (var b = 0; b < branches; b++) {
var s = main[R(6, Math.floor(main.length * 0.65))];
var side = Math.random() < 0.5 ? -1 : 1;
paths.push({ pts: jag(s[0], s[1], s[0] + side * R(0.08, 0.22, 3) * w, s[1] + R(0.12, 0.3, 3) * h, h * 0.07, 5), wd: 0.5 });
}
// Now and then: only a flash deep inside the clouds, no bolt.
var sheet = Math.random() < 0.25;
return {
x: x0, y: y0, paths: sheet ? [] : paths, power: sheet ? 0.5 : 1,
pulses: [[0, 1], [R(60, 110), R(0.5, 0.85, 2)], [R(160, 260), R(0.4, 0.9, 2)]],
age: 0
};
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.globalCompositeOperation = 'source-over';
ctx.clearRect(0, 0, w, h);
var f = strike ? envelope(strike.age) * strike.power : 0;
// The whole sky lights up a little.
if (f > 0.01) {
ctx.fillStyle = rgba(bolt, (f * 0.16).toFixed(3));
ctx.fillRect(0, 0, w, h);
}
if (rainy) {
ctx.strokeStyle = 'rgba(200,210,235,' + (0.22 + f * 0.4).toFixed(3) + ')';
ctx.lineWidth = 1;
ctx.beginPath();
for (var i = 0; i < rain.length; i++) {
var p = rain[i];
ctx.moveTo(p.x, p.y);
ctx.lineTo(p.x - p.l * 0.2, p.y + p.l);
}
ctx.stroke();
}
if (f > 0.01) {
ctx.globalCompositeOperation = 'lighter';
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
var unit = Math.max(0.7, Math.min(1.3, Math.min(w, h) / 360));
strike.paths.forEach(function (p) {
[[7, 0.18], [3, 0.55]].forEach(function (pass) {
ctx.strokeStyle = rgba(bolt, (f * pass[1]).toFixed(3));
ctx.lineWidth = pass[0] * p.wd * unit;
ctx.beginPath();
ctx.moveTo(p.pts[0][0], p.pts[0][1]);
for (var k = 1; k < p.pts.length; k++) ctx.lineTo(p.pts[k][0], p.pts[k][1]);
ctx.stroke();
});
ctx.strokeStyle = 'rgba(255,255,255,' + Math.min(1, f * 1.2).toFixed(3) + ')';
ctx.lineWidth = 1.2 * p.wd * unit;
ctx.stroke();
});
ctx.globalCompositeOperation = 'source-over';
}
// A solid ceiling of cloud behind the lumpy ones.
var top = ctx.createLinearGradient(0, 0, 0, h * 0.3);
top.addColorStop(0, rgba(cloudDark, 1));
top.addColorStop(1, rgba(cloudDark, 0));
ctx.fillStyle = top;
ctx.fillRect(0, 0, w, h * 0.3);
for (var c = 0; c < clouds.length; c++) {
var cl = clouds[c];
ctx.drawImage(cl.s.img, cl.x, cl.y, cl.s.w, cl.s.h);
}
// The clouds glow from inside where the bolt leaves them.
if (f > 0.01) {
ctx.globalCompositeOperation = 'lighter';
var rad = Math.max(w, h) * 0.55;
var g = ctx.createRadialGradient(strike.x, strike.y, 0, strike.x, strike.y, rad);
g.addColorStop(0, rgba(bolt, (f * 0.55).toFixed(3)));
g.addColorStop(0.4, rgba(bolt, (f * 0.12).toFixed(3)));
g.addColorStop(1, rgba(bolt, 0));
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
ctx.globalCompositeOperation = 'source-over';
}
}
function advance(dt) {
clock += dt;
if (strike) {
strike.age += dt;
if (strike.age > 900) strike = null;
}
if (clock >= next) {
strike = newStrike();
clock = 0;
next = interval * R(0.5, 1.5, 2);
}
// A solid ceiling of cloud behind the lumpy ones.
var top = ctx.createLinearGradient(0, 0, 0, h * 0.3);
top.addColorStop(0, rgba(cloudDark, 1));
top.addColorStop(1, rgba(cloudDark, 0));
ctx.fillStyle = top;
ctx.fillRect(0, 0, w, h * 0.3);
for (var c = 0; c < clouds.length; c++) {
var cl = clouds[c];
cl.x += (cl.v * dt) / 1000;
if (cl.x > w + 20) cl.x -= cl.span;
}
for (var i = 0; i < rain.length; i++) {
var p = rain[i];
p.y += (p.v * dt) / 1000;
p.x -= (p.v * 0.2 * dt) / 1000;
if (p.y > h) { p.y = -p.l - R(0, 40); p.x = R(0, w + h * 0.2, 1); }
}
}
resize();
if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
if (still) return;
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;
advance(dt);
draw();
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-lightning-storm]').forEach(function (el) {
if (el._ltn) return;
el._ltn = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section data-lightning-storm style="background: linear-gradient(#0f0c1d, #1e1b3a); color: #fff">
<h1>Feel the power</h1>
</section>