← Seasonal

Colour Powder Burst

Clouds of bright coloured powder burst up into the air, as at Holi. They billow out, drift and slowly fade while fine specks fall away. Bursts go off by themselves, and a click or tap throws one from that spot.

Happy Holi

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-colour-powder-burst to any section. The powder is drawn behind its content, so your text stays on top.

Options

Add any of these attributes to the same element.

data-colorsComma-separated powder colours. Default pink, yellow, green, blue, orange and purple.
data-intervalTime between bursts. Default 1100 ms; 0 for tap only.
data-amountHow much powder in each burst, as a multiplier. Default 1.
data-tap="false"No burst on click or tap.

The powder is clipped to the section, scales with its size and pauses when it is off screen. animacious.colourPowderBurst(el, x, y) throws one from script. With reduced motion a few clouds hang still.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Colour Powder Burst: clouds of bright coloured powder burst up into the
    // air, as at Holi, billow out, drift and slowly fade, while fine specks
    // fall away. Bursts go off by themselves every so often, and a click or
    // tap throws one from that spot. Drawn on a transparent canvas behind your
    // content, and paused when off screen.
    // Usage: <section data-colour-powder-burst>…</section>
    // Optional: data-colors (comma-separated powder colours, default pink,
    // yellow, green, blue, orange and purple), data-interval (ms between
    // bursts, default 1100; 0 for tap-only), data-amount (powder per burst,
    // a multiplier, default 1), data-tap="false" (no burst on click or tap).
    // Script use: animacious.colourPowderBurst(el, x, y) throws one at x, y
    // (px from the element's top left; the middle if left out).
    var DEFAULTS = { interval: 1100, amount: 1, colors: ['#ec4899', '#facc15', '#22c55e', '#3b82f6', '#f97316', '#a855f7'] };

    var css =
      '[data-colour-powder-burst]{position:relative;isolation:isolate;overflow:hidden}' +
      '.cpb-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);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    // A soft, lumpy puff of powder in one colour, pre-drawn once.
    var S = 128;
    function puff(color, seed) {
      var c = document.createElement('canvas');
      c.width = c.height = S;
      var g = c.getContext('2d');
      var rand = function () { seed = (seed * 16807) % 2147483647; return (seed - 1) / 2147483646; };
      for (var i = 0; i < 7; i++) {
        var x = S / 2 + (rand() - 0.5) * S * 0.3, y = S / 2 + (rand() - 0.5) * S * 0.3;
        var r = S * (0.22 + rand() * 0.16);
        var grd = g.createRadialGradient(x, y, 0, x, y, r);
        grd.addColorStop(0, color);
        grd.addColorStop(0.5, color);
        grd.addColorStop(1, 'rgba(255,255,255,0)');
        g.globalAlpha = 0.5;
        g.fillStyle = grd;
        g.beginPath();
        g.arc(x, y, r, 0, Math.PI * 2);
        g.fill();
      }
      return c;
    }

    function build(el) {
      var d = el.dataset;
      var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : DEFAULTS.colors;
      var interval = d.interval !== undefined ? +d.interval : DEFAULTS.interval;
      var amount = Math.max(0, d.amount !== undefined ? +d.amount : DEFAULTS.amount);
      var tap = d.tap !== 'false';

      var canvas = document.createElement('canvas');
      canvas.className = 'cpb-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var sprites = colors.map(function (c, i) { return [puff(c, 11 + i * 7), puff(c, 97 + i * 13), puff(c, 301 + i * 3)]; });
      var w = 0, h = 0, dpr = 1, unit = 1;
      var parts = [], lastColor = -1;

      function resize() {
        w = el.clientWidth; h = el.clientHeight;
        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));
        unit = Math.max(0.55, Math.min(1.6, Math.min(w, h * 1.4) / 380));
      }

      function burst(x, y) {
        if (!w || !h) return;
        if (x === undefined) { x = w * (0.15 + Math.random() * 0.7); y = h * (0.5 + Math.random() * 0.4); }
        var ci;
        do { ci = Math.floor(Math.random() * colors.length); } while (colors.length > 1 && ci === lastColor);
        lastColor = ci;
        // Sometimes a second colour mixes in.
        var ci2 = Math.random() < 0.35 ? (ci + 1 + Math.floor(Math.random() * (colors.length - 1))) % colors.length : ci;
        var spread = 0.9 + Math.random() * 0.5;
        var clouds = Math.round(24 * amount), specks = Math.round(36 * amount);
        for (var i = 0; i < clouds; i++) {
          var a = -Math.PI / 2 + (Math.random() - 0.5) * 2 * spread;
          var v = (90 + Math.random() * 330) * unit;
          parts.push({
            kind: 0, img: sprites[Math.random() < 0.75 ? ci : ci2][i % 3],
            x: x, y: y, vx: Math.cos(a) * v, vy: Math.sin(a) * v,
            r0: (10 + Math.random() * 16) * unit, grow: 2.4 + Math.random() * 1.8,
            rot: Math.random() * 6.28, spin: (Math.random() - 0.5) * 0.8,
            a0: 0.5 + Math.random() * 0.4, age: 0, life: 2200 + Math.random() * 1800
          });
        }
        for (i = 0; i < specks; i++) {
          a = -Math.PI / 2 + (Math.random() - 0.5) * 2.6 * spread;
          v = (200 + Math.random() * 420) * unit;
          parts.push({
            kind: 1, color: colors[Math.random() < 0.75 ? ci : ci2],
            x: x, y: y, vx: Math.cos(a) * v, vy: Math.sin(a) * v,
            r0: (1 + Math.random() * 1.8) * unit, a0: 0.9, age: 0, life: 1000 + Math.random() * 900
          });
        }
        if (parts.length > 900) parts.splice(0, parts.length - 900);
      }

      function step(dt) {
        var s = dt / 1000;
        for (var i = parts.length - 1; i >= 0; i--) {
          var p = parts[i];
          p.age += dt;
          if (p.age >= p.life) { parts.splice(i, 1); continue; }
          if (p.kind === 0) {
            var k = Math.exp(-3.2 * s);
            p.vx *= k; p.vy = p.vy * k - 6 * unit * s;   // the cloud slowly rises
            p.rot += p.spin * s;
          } else {
            k = Math.exp(-1.8 * s);
            p.vx *= k; p.vy = p.vy * k + 380 * unit * s;
          }
          p.x += p.vx * s; p.y += p.vy * s;
        }
      }

      function draw() {
        ctx.setTransform(1, 0, 0, 1, 0, 0);
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        for (var i = 0; i < parts.length; i++) {
          var p = parts[i], u = p.age / p.life;
          var alpha = p.a0 * (u < 0.05 ? u / 0.05 : Math.pow(1 - (u - 0.05) / 0.95, 1.6));
          if (alpha <= 0.003) continue;
          ctx.globalAlpha = alpha;
          if (p.kind === 0) {
            var r = p.r0 * (1 + (p.grow - 1) * (1 - Math.pow(1 - u, 3)));
            ctx.save();
            ctx.translate(p.x, p.y);
            ctx.rotate(p.rot);
            ctx.drawImage(p.img, -r * 1.6, -r * 1.6, r * 3.2, r * 3.2);
            ctx.restore();
          } else {
            ctx.fillStyle = p.color;
            ctx.beginPath();
            ctx.arc(p.x, p.y, p.r0, 0, Math.PI * 2);
            ctx.fill();
          }
        }
        ctx.globalAlpha = 1;
      }

      el._cpbBurst = function (x, y) { burst(x, y); };
      resize();

      if (reduced()) {
        // A still frame of a few settled clouds.
        [[0.25, 0.7], [0.72, 0.62], [0.5, 0.82]].forEach(function (b) { burst(w * b[0], h * b[1]); });
        for (var f = 0; f < 50; f++) step(16);
        draw();
        if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(); }).observe(el);
        return;
      }
      if (window.ResizeObserver) new ResizeObserver(resize).observe(el);

      if (tap) {
        el.addEventListener('pointerdown', function (e) {
          var rect = el.getBoundingClientRect();
          burst(e.clientX - rect.left, e.clientY - rect.top);
          quietUntil = performance.now() + interval * 2;
        });
      }

      var last = 0, nextBurst = 200, quietUntil = 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;
          if (interval > 0) {
            nextBurst -= dt;
            if (nextBurst <= 0) {
              if (now > quietUntil) burst();
              nextBurst = interval * (0.7 + Math.random() * 0.6);
            }
          }
          step(dt);
          draw();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    window.animacious = window.animacious || {};
    window.animacious.colourPowderBurst = function (el, x, y) {
      if (!el || !el._cpbBurst) return;
      if (x === undefined) { x = el.clientWidth / 2; y = el.clientHeight / 2; }
      el._cpbBurst(x, y);
    };

    function init() {
      document.querySelectorAll('[data-colour-powder-burst]').forEach(function (el) {
        if (el._cpb) return;   // idempotent
        el._cpb = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-colour-powder-burst>
  <h2>Happy Holi</h2>
</section>

<!-- pinks and purples, only when tapped: -->
<section data-colour-powder-burst data-interval="0" data-colors="#ec4899,#a855f7,#f0abfc">…</section>