Spark Shower
Bright sparks spray out of a point like an angle grinder, arc down under gravity and skip along the bottom, cooling from white-hot to red. The spray follows your pointer or finger, and sweeps about by itself otherwise.
Move to spray
How to use it
- Paste the snippet just before
</head>. - Add
data-spark-showerto a section with a dark background.
Options
Add any of these attributes to the same element.
data-x / data-y | Where the spray starts, as a share of the width and height. Default 0.25 and 0.3. |
data-angle | Direction of the spray in degrees: 0 is right, -90 is up. Default -25. |
data-spread | How wide the spray fans out, in degrees. Default 50. |
data-rate | Sparks per second. Default 260. |
data-follow="false" | Ignore the pointer. |
It’s drawn on a transparent canvas behind your content and paused when off screen. With reduced motion you get a still frame.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Spark Shower: bright sparks spray out of a point, like an angle grinder,
// arc down under gravity and skip along the bottom of the section, cooling
// from white-hot to orange to red. The spray follows the pointer (or your
// finger) and sweeps about by itself otherwise. Drawn on a transparent
// canvas behind your content, and paused when off screen.
// Usage: <section data-spark-shower style="background:#0b0b10">…</section>
// Optional: data-x and data-y (where the spray starts, as a share of the
// width and height, default 0.25 and 0.3), data-angle (direction in
// degrees, 0 is right, -90 up, default -25), data-spread (degrees, default
// 50), data-rate (sparks per second, default 260), data-follow="false"
// (ignore the pointer).
var DEFAULTS = { x: 0.25, y: 0.3, angle: -25, spread: 50, rate: 260 };
var css =
'[data-spark-shower]{position:relative;isolation:isolate;overflow:hidden;touch-action:pan-y}' +
'.sps-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);
// Spark colour by heat, 1 (white-hot) to 0 (dull red).
function heat(k) {
if (k > 0.7) return 'rgb(255,' + Math.round(235 + 20 * (k - 0.7) / 0.3) + ',' + Math.round(150 + 105 * (k - 0.7) / 0.3) + ')';
if (k > 0.35) return 'rgb(255,' + Math.round(140 + 95 * (k - 0.35) / 0.35) + ',' + Math.round(30 + 120 * (k - 0.35) / 0.35) + ')';
return 'rgb(' + Math.round(170 + 85 * k / 0.35) + ',' + Math.round(40 + 100 * k / 0.35) + ',20)';
}
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'sps-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var fx = d.x !== undefined ? +d.x : DEFAULTS.x;
var fy = d.y !== undefined ? +d.y : DEFAULTS.y;
var baseAngle = (d.angle !== undefined ? +d.angle : DEFAULTS.angle) * Math.PI / 180;
var spread = (+d.spread || DEFAULTS.spread) * Math.PI / 180;
var rate = +d.rate || DEFAULTS.rate;
var R = anime.utils.random;
var w = 0, h = 0, dpr = 1, t = 0, unit = 1;
var sparks = [], pointer = null, ex = 0, ey = 0, owed = 0;
function resize() {
dpr = Math.min(2, window.devicePixelRatio || 1);
w = el.clientWidth;
h = el.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
unit = Math.max(0.6, Math.min(1.4, Math.sqrt(w * h) / 560));
}
function emit(angle) {
var a = angle + R(-0.5, 0.5, 3) * spread;
var v = R(220, 640) * unit;
sparks.push({
x: ex, y: ey, px: ex, py: ey,
vx: Math.cos(a) * v, vy: Math.sin(a) * v,
age: 0, life: R(0.7, 1.6, 2),
bounces: 0,
width: R(1, 2.2, 2)
});
}
function advance(dt) {
t += dt;
// Where it sprays from: the pointer, or a point that drifts gently.
var tx = pointer ? pointer.x : w * fx + Math.sin(t * 0.5) * w * 0.06;
var ty = pointer ? pointer.y : h * fy + Math.sin(t * 0.8) * h * 0.05;
ex += (tx - ex) * Math.min(1, dt * 12);
ey += (ty - ey) * Math.min(1, dt * 12);
var angle = baseAngle + (pointer ? 0 : Math.sin(t * 0.7) * 0.35);
// The spray comes in surges, like a grinder biting.
var surge = 0.55 + 0.45 * Math.max(0, Math.sin(t * 2.3) * Math.sin(t * 0.9 + 1));
owed += rate * surge * dt * Math.max(0.75, Math.min(1.6, w / 700));
while (owed >= 1) { emit(angle); owed--; }
var g = 980 * unit, floor = h - 2;
for (var i = sparks.length - 1; i >= 0; i--) {
var s = sparks[i];
s.age += dt;
s.px = s.x; s.py = s.y;
s.vy += g * dt;
s.vx *= Math.exp(-0.4 * dt);
s.x += s.vx * dt;
s.y += s.vy * dt;
if (s.y > floor && s.vy > 0) {
// Skip along the floor, losing most of the bounce each time.
s.y = floor;
s.vy *= -R(0.25, 0.45, 2);
s.vx *= 0.7;
s.bounces++;
if (s.bounces > 2) s.age = s.life;
}
if (s.age >= s.life || s.x < -20 || s.x > w + 20) sparks.splice(i, 1);
}
if (sparks.length > 1600) sparks.splice(0, sparks.length - 1600);
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
ctx.globalCompositeOperation = 'lighter';
ctx.lineCap = 'round';
for (var i = 0; i < sparks.length; i++) {
var s = sparks[i], k = 1 - s.age / s.life;
// A streak from where it was a moment ago: longer when it's fast.
var tail = 0.022;
ctx.strokeStyle = heat(k);
ctx.globalAlpha = Math.min(1, k * 1.6);
ctx.lineWidth = s.width * (0.5 + k * 0.6);
ctx.beginPath();
ctx.moveTo(s.x - s.vx * tail, s.y - s.vy * tail);
ctx.lineTo(s.x, s.y);
ctx.stroke();
}
// The glowing hot spot where the sparks come from.
ctx.globalAlpha = 1;
var r = 26 * unit, glow = ctx.createRadialGradient(ex, ey, 0, ex, ey, r);
glow.addColorStop(0, 'rgba(255,250,230,.95)');
glow.addColorStop(0.18, 'rgba(255,200,90,.6)');
glow.addColorStop(1, 'rgba(255,120,20,0)');
ctx.fillStyle = glow;
ctx.fillRect(ex - r, ey - r, r * 2, r * 2);
ctx.globalCompositeOperation = 'source-over';
}
resize();
ex = w * fx; ey = h * fy;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
for (var i = 0; i < 60; i++) advance(1 / 60);
draw();
if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(); }).observe(el);
return;
}
if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
if (d.follow !== 'false') {
var move = function (e) {
var r = el.getBoundingClientRect();
pointer = { x: e.clientX - r.left, y: e.clientY - r.top };
};
el.addEventListener('pointermove', move);
el.addEventListener('pointerdown', move);
el.addEventListener('pointerleave', function () { pointer = null; });
el.addEventListener('pointercancel', function () { pointer = null; });
el.addEventListener('pointerup', function (e) { if (e.pointerType !== 'mouse') 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) / 1000 : 1 / 60;
last = now;
advance(dt);
draw();
}
});
// Pause while off screen to save battery.
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-spark-shower]').forEach(function (el) {
if (el._sps) return;
el._sps = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section data-spark-shower style="background: #0b0b10; color: #fff">
<h1>Made in the forge</h1>
</section>