Click Sparks
Wherever you click or tap, a ring of short sparks bursts out from the spot, shooting outwards and shrinking away to nothing in under half a second.
Click anywhere
How to use it
- Paste the snippet just before
</head>. - Add
data-click-sparksto a section, or to<body>for sparks anywhere on the page.
Options
Add any of these attributes to the same element.
data-color | Comma-separated spark colours, used in turn round the ring. Default #a855f7. |
data-count | Sparks per click. Default 8. |
data-size | How far the sparks fly, in px. Default 28. |
data-length | Length of each spark, in px. Default 12. |
data-duration | How long a burst lasts. Default 450 ms. |
data-autoplay | Pretend clicks pop up round the section while nobody’s clicking. Handy for demos. |
The sparks are drawn over the section without catching clicks, so links and buttons underneath still work. With reduced motion turned on, there are no sparks.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Click Sparks: wherever you click or tap, a ring of short sparks bursts
// out from the spot, shooting outwards and shrinking away to nothing.
// Usage: <section data-click-sparks>…</section>
// or <body data-click-sparks> for sparks anywhere on the page.
// Optional: data-color (comma-separated spark colours, default #a855f7),
// data-count (sparks per click, default 8), data-size (how far they fly,
// px, default 28), data-length (length of each spark, px, default 12),
// data-duration (ms, default 450), data-autoplay (pretend clicks pop up
// around the area while nobody is clicking; handy for demos).
// The sparks are drawn over the area without catching clicks, so links
// and buttons underneath still work.
var DEFAULTS = { color: '#a855f7', count: 8, size: 28, length: 12, duration: 450 };
var css =
'.cks{position:relative}' +
'.cks-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:5;pointer-events:none}' +
'body > .cks-canvas{position:fixed}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function num(v, def) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : def; }
function outQuart(p) { return 1 - Math.pow(1 - p, 4); }
function setup(el) {
var d = el.dataset;
var page = el === document.body;
var colours = (d.color || DEFAULTS.color).split(',').map(function (c) { return c.trim(); });
var count = Math.max(1, Math.min(32, Math.round(num(d.count, DEFAULTS.count))));
var reach = num(d.size, DEFAULTS.size);
var len = num(d.length, DEFAULTS.length);
var dur = Math.max(100, num(d.duration, DEFAULTS.duration));
var auto = d.autoplay !== undefined && d.autoplay !== 'false';
el.classList.add('cks');
var canvas = document.createElement('canvas');
canvas.className = 'cks-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.appendChild(canvas);
var ctx = canvas.getContext('2d');
var w = 0, h = 0;
function size() {
w = page ? window.innerWidth : el.clientWidth;
h = page ? window.innerHeight : el.clientHeight;
var dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.max(1, Math.round(w * dpr));
canvas.height = Math.max(1, Math.round(h * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
size();
if (page) window.addEventListener('resize', size);
else new ResizeObserver(size).observe(el);
var bursts = [], raf = 0, visible = page, lastReal = 0, nextFake = 0;
function burst(x, y) {
var spin = Math.random() * Math.PI * 2, c = 0;
var b = { x: x, y: y, t0: performance.now(), sparks: [] };
for (var i = 0; i < count; i++) {
b.sparks.push({
a: spin + i * Math.PI * 2 / count,
// Every other spark is a little shorter, which reads livelier.
l: len * (i % 2 ? 0.7 : 1),
r: reach * (i % 2 ? 0.85 : 1),
c: colours[c++ % colours.length]
});
}
bursts.push(b);
if (!raf) raf = requestAnimationFrame(frame);
}
function frame(now) {
raf = 0;
ctx.clearRect(0, 0, w, h);
ctx.lineCap = 'round';
ctx.lineWidth = 2.5;
for (var i = bursts.length - 1; i >= 0; i--) {
var b = bursts[i], p = Math.min(1, (now - b.t0) / dur);
if (p >= 1) { bursts.splice(i, 1); continue; }
var e = outQuart(p);
b.sparks.forEach(function (s) {
// The spark flies out while its tail catches up, so it shrinks to nothing.
var d0 = s.r * e, d1 = d0 + s.l * (1 - e);
var ca = Math.cos(s.a), sa = Math.sin(s.a);
ctx.strokeStyle = s.c;
ctx.beginPath();
ctx.moveTo(b.x + ca * (d0 + 4), b.y + sa * (d0 + 4));
ctx.lineTo(b.x + ca * (d1 + 4), b.y + sa * (d1 + 4));
ctx.stroke();
});
}
if (auto && visible && now >= nextFake && now - lastReal > 3000) {
// Pretend clicks, scattered round the area at a relaxed pace.
nextFake = now + 650 + Math.random() * 650;
burst(w * (0.15 + Math.random() * 0.7), h * (0.18 + Math.random() * 0.64));
}
if (bursts.length || (auto && visible)) raf = requestAnimationFrame(frame);
}
function wake() { if (!raf) raf = requestAnimationFrame(frame); }
var target = page ? document : el;
target.addEventListener('pointerdown', function (e) {
if (e.button) return;
lastReal = performance.now();
if (page) burst(e.clientX, e.clientY);
else {
var b = el.getBoundingClientRect();
burst(e.clientX - b.left, e.clientY - b.top);
}
});
if (!page) {
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (visible && auto) wake();
}).observe(el);
}
}
function init() {
if (reduced()) return;
document.querySelectorAll('[data-click-sparks]').forEach(function (el) {
if (el._cks) return;
el._cks = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><body data-click-sparks>
<!-- bigger, colourful bursts in one section: -->
<section data-click-sparks data-color="#f43f5e,#f59e0b,#22c55e" data-count="12" data-size="40">…</section>