← Hover & cursor

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

  1. Paste the snippet just before </head>.
  2. Add data-click-sparks to a section, or to <body> for sparks anywhere on the page.

Options

Add any of these attributes to the same element.

data-colorComma-separated spark colours, used in turn round the ring. Default #a855f7.
data-countSparks per click. Default 8.
data-sizeHow far the sparks fly, in px. Default 28.
data-lengthLength of each spark, in px. Default 12.
data-durationHow long a burst lasts. Default 450 ms.
data-autoplayPretend 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.

1 · Snippet
<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>
2 · Your HTML
<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>