← Particles

Firework Message

A rocket climbs into the night sky and bursts, but instead of a round shower its sparks fly out to spell a word. The letters glitter for a moment, then droop and fade like real fireworks before the next word goes up. Click to launch one early.

Click or tap to launch

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-firework-message to a section with a dark background, and put the words in data-text, separated by commas.
  3. The words are drawn in the middle of the section, so keep your own content near the edges.

Options

Add any of these attributes to the same element.

data-textThe words to spell, comma-separated, taken in turn. Default Hooray.
data-loopKeep going round the words. Without it, each one goes up once.
data-holdHow long each word stays lit. Default 1600 ms.
data-gapSpace between the sparks in the letters, in px. Default 5.
data-colorsComma-separated colours, one per word in turn.

Short words read best on a phone; long ones are shrunk to fit the width. It’s drawn on a canvas behind your content and paused when off screen. If the section has no text of its own, the words become its aria-label. With reduced motion the first word is shown lit and still. From code, animacious.fireworkMessage(section, "Thanks!") launches a word.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Firework Message: a rocket climbs from the bottom of a section and bursts,
    // and instead of a round shower its sparks fly out to spell a word in the
    // sky. The word glitters for a moment, then the sparks droop and fade like
    // real fireworks and the next word goes up. Drawn on a canvas behind your
    // content, and paused when off screen.
    // Usage: <section data-firework-message data-text="Happy,New Year">…</section>
    // Optional: data-text (comma-separated words or short phrases, taken in
    // turn; default "Hooray"), data-colors (comma-separated, one per word in
    // turn), data-hold (ms each word stays lit, default 1600), data-gap (px
    // between sparks in the letters, default 5), data-loop (keep going; without
    // it each word goes up once). Click inside to launch the next word.
    // Script use: animacious.fireworkMessage(element, 'Text') launches a word.
    var DEFAULTS = { text: 'Hooray', hold: 1600, gap: 5, colors: ['#facc15', '#f472b6', '#38bdf8', '#a78bfa', '#4ade80', '#fb923c'] };
    var FONT = '800 {s}px system-ui, -apple-system, "Segoe UI", sans-serif';

    var css =
      '[data-firework-message]{position:relative;isolation:isolate;overflow:hidden}' +
      '.fwm-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; }

    // Sample the pixels of a word drawn as big as fits, and return points
    // (relative to the word's centre) every `gap` px.
    function wordPoints(text, w, h, gap) {
      var maxW = w * 0.86, maxH = h * 0.34;
      var c = document.createElement('canvas');
      var x = c.getContext('2d');
      var size = Math.min(maxH, 160);
      x.font = FONT.replace('{s}', size);
      var tw = x.measureText(text).width;
      if (tw > maxW) { size *= maxW / tw; x.font = FONT.replace('{s}', size); tw = x.measureText(text).width; }
      var cw = Math.ceil(tw + 8), ch = Math.ceil(size * 1.3);
      c.width = cw; c.height = ch;
      x.font = FONT.replace('{s}', size);
      x.textAlign = 'center';
      x.textBaseline = 'middle';
      x.fillStyle = '#000';
      x.fillText(text, cw / 2, ch / 2);
      var data = x.getImageData(0, 0, cw, ch).data;
      var pts = [];
      var g = Math.max(2, gap * Math.min(1, size / 70));
      for (var y = 0; y < ch; y += g) {
        for (var xx = 0; xx < cw; xx += g) {
          var jx = xx + (Math.random() - 0.5) * g * 0.5, jy = y + (Math.random() - 0.5) * g * 0.5;
          var i = ((Math.round(jy) * cw + Math.round(jx)) * 4) + 3;
          if (data[i] > 128) pts.push({ x: jx - cw / 2, y: jy - ch / 2, s: g });
        }
      }
      return pts;
    }

    function build(el) {
      var d = el.dataset;
      var canvas = document.createElement('canvas');
      canvas.className = 'fwm-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var words = (d.text || DEFAULTS.text).split(',').map(function (s) { return s.trim(); }).filter(Boolean);
      var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : DEFAULTS.colors;
      var hold = d.hold !== undefined ? +d.hold : DEFAULTS.hold;
      var gap = +d.gap || DEFAULTS.gap;
      var loop = d.loop !== undefined && d.loop !== 'false';
      if (!el.hasAttribute('aria-label') && !el.textContent.trim()) el.setAttribute('aria-label', words.join(', '));
      var w = 0, h = 0, dpr = 1, unit = 1;
      var rockets = [], sparks = [], next = 0, wait = 300, launched = 0, colorIndex = 0;

      function resize() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        unit = Math.max(0.4, h / 700);
        canvas.width = w * dpr;
        canvas.height = h * dpr;
      }
      resize();
      if (window.ResizeObserver) new ResizeObserver(resize).observe(el);

      function launch(text) {
        if (!w || !h) return;
        if (text === undefined) { text = words[next % words.length]; next++; }
        var color = colors[colorIndex++ % colors.length];
        var tx = w / 2 + (Math.random() - 0.5) * w * 0.08, ty = h * 0.42;
        var g = 0.0009 * unit;
        var vy = -Math.sqrt(2 * g * (h - ty));
        var t = -vy / g;
        var x = w / 2 + (Math.random() - 0.5) * w * 0.2;
        rockets.push({ x: x, y: h, vx: (tx - x) / t, vy: vy, g: g, color: color, text: text, tx: tx, ty: ty, trail: [] });
        launched++;
      }

      // The burst: every spark is assigned a point in the word and flies there
      // on an ease-out curve, overshooting the round shape of a normal burst.
      function burst(r) {
        var pts = wordPoints(r.text, w, h, gap);
        var life = hold + 1400;
        pts.forEach(function (p) {
          var tx = r.tx + p.x, ty = r.ty + p.y;
          sparks.push({
            sx: r.x, sy: r.y, tx: tx, ty: ty, x: r.x, y: r.y, vx: 0, vy: 0,
            age: -Math.random() * 120, fly: 650 + Math.random() * 350, life: life + Math.random() * 500,
            color: Math.random() < 0.18 ? '#ffffff' : r.color, tw: Math.random() * 6.28, word: true, s: Math.max(1.6, p.s * 0.55)
          });
        });
        // A few loose sparks round the edge, so it still reads as a firework.
        for (var i = 0; i < 46; i++) {
          var a = Math.random() * Math.PI * 2, s = (0.12 + Math.random() * 0.2) * unit;
          sparks.push({ x: r.x, y: r.y, vx: Math.cos(a) * s, vy: Math.sin(a) * s, age: 0, life: 900 + Math.random() * 600, color: r.color, tw: 0, word: false });
        }
      }

      function step(dt) {
        wait -= dt;
        if (wait <= 0 && (loop || launched < words.length)) { launch(); wait = hold + 2600; }
        for (var i = rockets.length - 1; i >= 0; i--) {
          var r = rockets[i];
          r.trail.push(r.x, r.y);
          if (r.trail.length > 16) r.trail.splice(0, 2);
          r.x += r.vx * dt;
          r.y += r.vy * dt;
          r.vy += r.g * dt;
          if (r.vy >= -0.02) { burst(r); rockets.splice(i, 1); }
        }
        var g = 0.00018 * unit;
        for (var j = sparks.length - 1; j >= 0; j--) {
          var p = sparks[j];
          p.age += dt;
          if (p.age < 0) continue;
          if (p.word) {
            if (p.age < p.fly) {
              var k = p.age / p.fly, e = 1 - Math.pow(1 - k, 4);
              p.x = p.sx + (p.tx - p.sx) * e;
              p.y = p.sy + (p.ty - p.sy) * e;
            } else if (p.age > p.life - 1200) {
              // Let go: drift down under gravity as it fades.
              p.vy += g * dt;
              p.vx *= 0.995;
              p.x += p.vx * dt;
              p.y += p.vy * dt;
            } else {
              p.x = p.tx + Math.sin(p.age * 0.004 + p.tw) * 0.5;
              p.y = p.ty + Math.cos(p.age * 0.003 + p.tw) * 0.5;
            }
          } else {
            p.vy += g * dt;
            p.vx *= 0.99; p.vy *= 0.99;
            p.x += p.vx * dt;
            p.y += p.vy * dt;
          }
          if (p.age > p.life) sparks.splice(j, 1);
        }
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        ctx.globalCompositeOperation = 'lighter';
        rockets.forEach(function (r) {
          for (var i = 0; i < r.trail.length; i += 2) {
            ctx.globalAlpha = (i / r.trail.length) * 0.8;
            ctx.fillStyle = r.color;
            ctx.fillRect(r.trail[i] - 1, r.trail[i + 1] - 1, 2, 2);
          }
          ctx.globalAlpha = 1;
          ctx.fillStyle = '#fff';
          ctx.beginPath();
          ctx.arc(r.x, r.y, 2.2, 0, 6.283);
          ctx.fill();
        });
        for (var j = 0; j < sparks.length; j++) {
          var p = sparks[j];
          if (p.age < 0) continue;
          var fade = Math.min(1, (p.life - p.age) / 1000);
          var tw = p.word && p.age > p.fly ? 0.65 + 0.35 * Math.sin(p.age * 0.012 + p.tw * 3) : 1;
          ctx.globalAlpha = Math.max(0, fade * tw);
          ctx.fillStyle = p.color;
          var s = p.word ? p.s : 1.6;
          if (p.word && p.age < p.fly) {
            // A short streak while it's flying into place.
            ctx.strokeStyle = p.color;
            ctx.lineWidth = s;
            ctx.beginPath();
            var k = Math.max(0, (p.age - 40) / p.fly), e = 1 - Math.pow(1 - k, 4);
            ctx.moveTo(p.sx + (p.tx - p.sx) * e, p.sy + (p.ty - p.sy) * e);
            ctx.lineTo(p.x, p.y);
            ctx.stroke();
          } else {
            ctx.fillRect(p.x - s / 2, p.y - s / 2, s, s);
            // A faint halo, so the letters glow.
            ctx.globalAlpha *= 0.18;
            ctx.fillRect(p.x - s * 1.5, p.y - s * 1.5, s * 3, s * 3);
          }
        }
        ctx.globalAlpha = 1;
        ctx.globalCompositeOperation = 'source-over';
      }

      el._fwmLaunch = function (text) { launch(text); wait = Math.max(wait, hold + 2600); };

      if (reduced()) {
        // A still frame: the first word, lit.
        var pts = wordPoints(words[0], w, h, gap);
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.fillStyle = colors[0];
        pts.forEach(function (p) { var s0 = Math.max(1.6, p.s * 0.55); ctx.fillRect(w / 2 + p.x - s0 / 2, h * 0.42 + p.y - s0 / 2, s0, s0); });
        return;
      }

      el.addEventListener('click', function () { el._fwmLaunch(); });

      var last = 0;
      var timer = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = last ? Math.min(40, now - last) : 16;
          last = now;
          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.fireworkMessage = function (el, text) { if (el && el._fwmLaunch) el._fwmLaunch(text); };

    function init() {
      document.querySelectorAll('[data-firework-message]').forEach(function (el) {
        if (el._fwm) return;
        el._fwm = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-firework-message data-loop data-text="Happy,New Year" style="background: #0b0b1a">
</section>