← Particles

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

  1. Paste the snippet just before </head>.
  2. Add data-spark-shower to a section with a dark background.

Options

Add any of these attributes to the same element.

data-x / data-yWhere the spray starts, as a share of the width and height. Default 0.25 and 0.3.
data-angleDirection of the spray in degrees: 0 is right, -90 is up. Default -25.
data-spreadHow wide the spray fans out, in degrees. Default 50.
data-rateSparks 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.

1 · Snippet
<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>
2 · Your HTML
<section data-spark-shower style="background: #0b0b10; color: #fff">
  <h1>Made in the forge</h1>
</section>