← Seasonal

Birthday Candles

Striped candles flicker on top of a cake. Then a puff of air sweeps across and blows them out one after another, and each wick sends up a curl of smoke. Loop it and they light themselves again.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty <div data-birthday-candles> where the cake should go. The candles are blown out a moment after it scrolls into view.

Options

Add any of these attributes to the same element.

data-loopLight the candles again and blow them out again.
data-countHow many candles, 1 to 9. Default 5.
data-colorsComma-separated candle colours. Default blue, pink, yellow, violet and green.
data-holdHow long the candles burn before they are blown out. Default 2200 ms.
data-cakeIcing colour. Default pink #fbcfe8.

It is 8:5 with a transparent background by default; set your own height or background on the element. animacious.birthdayCandles(el) blows them out, or lights them again if they are out. With reduced motion the candles burn steadily.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Birthday Candles: a cake with a row of striped candles. The flames
    // flicker, then a puff of air sweeps across and blows them out one after
    // another, each wick sending up a curl of smoke. Loop it and they light
    // themselves again. Drawn as SVG that fills the element.
    // Usage: <div data-birthday-candles></div>
    // It is 8:5 by default with a transparent background.
    // Optional: data-loop (relight and blow out again), data-count (candles,
    // 1 to 9, default 5), data-colors (comma-separated candle colours),
    // data-hold (ms the candles burn before they are blown out, default
    // 2200), data-cake (icing colour, default #fbcfe8).
    // Script use: animacious.birthdayCandles(el) blows them out (or relights them).
    var DEFAULTS = { count: 5, hold: 2200, cake: '#fbcfe8', colors: ['#38bdf8', '#f472b6', '#facc15', '#a78bfa', '#4ade80'] };
    var uid = 0;
    var SMOKE = 'M0 0 C-4 -8 4 -14 0 -22 C-5 -30 8 -38 3 -48 C-3 -58 12 -64 6 -76';
    var SMOKE2 = 'M0 0 C4 -7 -3 -13 1 -20 C6 -28 -6 -34 -2 -44 C3 -52 -10 -58 -5 -66';

    var css =
      '[data-birthday-candles]{display:block;position:relative;overflow:hidden;width:100%;aspect-ratio:8/5}' +
      '.bdc-svg{position:absolute;inset:0;width:100%;height:100%;display:block}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function build(el) {
      var d = el.dataset;
      var n = ++uid;
      var g = 'bdc' + n;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var count = Math.max(1, Math.min(9, d.count !== undefined ? Math.round(+d.count) : DEFAULTS.count));
      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 icing = d.cake || DEFAULTS.cake;
      if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'Birthday candles flicker, then blow out in curls of smoke');
      if (!el.hasAttribute('role')) el.setAttribute('role', 'img');

      var patterns = '';
      colors.forEach(function (c, i) {
        patterns += '<pattern id="' + g + '-p' + i + '" width="9" height="9" patternUnits="userSpaceOnUse" patternTransform="rotate(-35)">' +
          '<rect width="9" height="9" fill="' + c + '"/><rect width="9" height="3.6" fill="#fff" fill-opacity=".85"/></pattern>';
      });

      // Candles across the top of the cake, a little up and down for depth.
      var candles = '';
      var spread = Math.min(132, 30 * (count - 1));
      for (var i = 0; i < count; i++) {
        var x = 200 + (count > 1 ? -spread / 2 + spread * i / (count - 1) : 0);
        var y = 132 + (i % 2 ? -5 : 4);
        candles += '<g transform="translate(' + x.toFixed(1) + ' ' + y + ')">' +
          '<rect x="-4.5" y="-46" width="9" height="46" rx="2" fill="url(#' + g + '-p' + (i % colors.length) + ')"/>' +
          '<rect x="-4.5" y="-46" width="9" height="46" rx="2" fill="url(#' + g + '-round)"/>' +
          '<path d="M0 -46 L0 -51" stroke="#292524" stroke-width="1.6" stroke-linecap="round"/>' +
          '<circle class="bdc-ember" cx="0" cy="-51" r="1.8" fill="#fb923c" opacity="0"/>' +
          '<g transform="translate(0 -52)" fill="none" stroke-linecap="round">' +
            '<path class="bdc-smoke" d="' + SMOKE + '" stroke="#e5e7eb" stroke-width="2.2" opacity="0"/>' +
            '<path class="bdc-smoke" d="' + SMOKE2 + '" stroke="#d1d5db" stroke-width="1.6" opacity="0"/>' +
          '</g>' +
          '<g class="bdc-flame" transform="translate(0 -50)">' +
            '<circle r="20" cy="-8" fill="url(#' + g + '-halo)"/>' +
            '<path d="M0 0 C-5 -2 -6 -10 0 -21 C6 -10 5 -2 0 0 Z" fill="url(#' + g + '-fire)"/>' +
            '<path d="M0 -1 C-2.2 -2.4 -2.2 -6 0 -10 C2.2 -6 2.2 -2.4 0 -1 Z" fill="#fffbeb"/>' +
          '</g>' +
          '</g>';
      }

      var sprinkles = '';
      var SPR = ['#38bdf8', '#facc15', '#a78bfa', '#4ade80', '#fb7185'];
      for (var k = 0; k < 16; k++) {
        var sx = 128 + (k * 47) % 146, sy = 168 + (k * 29) % 38;
        sprinkles += '<rect x="' + sx + '" y="' + sy + '" width="6" height="2.2" rx="1.1" fill="' + SPR[k % SPR.length] + '" transform="rotate(' + ((k * 53) % 180) + ' ' + (sx + 3) + ' ' + (sy + 1) + ')"/>';
      }

      el.innerHTML =
        '<svg class="bdc-svg" viewBox="0 0 400 250" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false">' +
        '<defs>' + patterns +
        '<linearGradient id="' + g + '-round" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#000" stop-opacity=".2"/><stop offset=".4" stop-color="#fff" stop-opacity=".25"/><stop offset="1" stop-color="#000" stop-opacity=".25"/></linearGradient>' +
        '<linearGradient id="' + g + '-sponge" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#b45309"/><stop offset=".35" stop-color="#f59e0b"/><stop offset=".7" stop-color="#d97706"/><stop offset="1" stop-color="#92400e"/></linearGradient>' +
        '<radialGradient id="' + g + '-fire" cx=".5" cy=".8" r=".75"><stop offset="0" stop-color="#fff7d6"/><stop offset=".45" stop-color="#fbbf24"/><stop offset="1" stop-color="#ea580c"/></radialGradient>' +
        '<radialGradient id="' + g + '-halo"><stop offset="0" stop-color="#fde68a" stop-opacity=".55"/><stop offset=".5" stop-color="#f97316" stop-opacity=".12"/><stop offset="1" stop-color="#f97316" stop-opacity="0"/></radialGradient>' +
        '<radialGradient id="' + g + '-warm" cx=".5" cy=".3"><stop offset="0" stop-color="#fde68a" stop-opacity=".35"/><stop offset="1" stop-color="#fde68a" stop-opacity="0"/></radialGradient>' +
        '</defs>' +
        '<ellipse class="bdc-warm" cx="200" cy="120" rx="190" ry="110" fill="url(#' + g + '-warm)"/>' +
        // Plate, sponge, icing with drips, sprinkles.
        '<ellipse cx="200" cy="224" rx="128" ry="16" fill="#e5e7eb"/><ellipse cx="200" cy="221" rx="116" ry="12" fill="#f8fafc"/>' +
        '<path d="M110 136 L110 212 A90 16 0 0 0 290 212 L290 136 Z" fill="url(#' + g + '-sponge)"/>' +
        '<path d="M110 186 A90 16 0 0 0 290 186" fill="none" stroke="' + icing + '" stroke-width="5"/>' +
        '<path d="M110 136 L110 156 Q116 168 122 156 Q130 176 138 158 Q146 166 154 159 Q164 182 174 160 Q186 170 198 161 Q208 178 218 160 Q230 168 240 158 Q250 176 260 156 Q268 166 276 154 Q284 170 290 152 L290 136 Z" fill="' + icing + '"/>' +
        '<ellipse cx="200" cy="136" rx="90" ry="17" fill="' + icing + '"/>' +
        '<ellipse cx="200" cy="134" rx="84" ry="13" fill="#fff" opacity=".35"/>' +
        sprinkles +
        candles +
        '<g class="bdc-wind" opacity="0" fill="none" stroke="#fff" stroke-opacity=".6" stroke-width="2" stroke-linecap="round">' +
          '<path d="M0 64 Q30 58 60 64 T120 64"/><path d="M-20 84 Q20 78 60 84 T140 84"/><path d="M10 100 Q40 95 70 100"/>' +
        '</g>' +
        '</svg>';

      var svg = el.firstChild;
      var warm = svg.querySelector('.bdc-warm');
      var wind = svg.querySelector('.bdc-wind');
      var cs = [];
      svg.querySelectorAll('.bdc-flame').forEach(function (f, i) {
        var p = f.parentNode;
        cs.push({ el: f, lit: 1, lean: 0, ember: 0, ph: i * 1.9, f1: 7 + (i * 1.3) % 3, f2: 13 + (i * 2.9) % 5,
          emberEl: p.querySelector('.bdc-ember'), smoke: p.querySelectorAll('.bdc-smoke') });
      });
      var smokeLen = 80;
      try { smokeLen = cs[0].smoke[0].getTotalLength() + 2; } catch (e) {}
      var W = { x: -160, o: 0 };

      var t = 0;
      function frame(dt) {
        t += dt;
        var s = t / 1000, total = 0;
        cs.forEach(function (c) {
          var flick = 0.07 * Math.sin(s * c.f1 + c.ph) + 0.05 * Math.sin(s * c.f2 + c.ph * 2) + 0.03 * Math.sin(s * 31 + c.ph);
          var sy = c.lit * (1 + flick + c.lean * -0.006);
          var sx = c.lit * (1 - flick * 0.5);
          var skew = c.lit * (2.5 * Math.sin(s * 2.4 + c.ph)) + c.lean;
          c.el.setAttribute('transform', 'translate(0 -50) skewX(' + (-skew).toFixed(2) + ') scale(' + Math.max(0, sx).toFixed(3) + ' ' + Math.max(0, sy).toFixed(3) + ')');
          c.emberEl.setAttribute('opacity', c.ember.toFixed(2));
          total += c.lit;
        });
        warm.setAttribute('opacity', (total / cs.length).toFixed(3));
        wind.setAttribute('opacity', W.o.toFixed(2));
        wind.setAttribute('transform', 'translate(' + W.x.toFixed(1) + ' 0)');
      }

      if (reduced()) { frame(0); return; }

      function puffSmoke(c, delay) {
        c.smoke.forEach(function (p, j) {
          anime.animate(p, {
            strokeDasharray: [smokeLen + ' ' + smokeLen, smokeLen + ' ' + smokeLen],
            strokeDashoffset: [smokeLen, 0],
            opacity: [{ from: 0.85, to: 0.85, duration: 500 }, { to: 0, duration: 1300 }],
            y: [0, -18 - j * 6],
            x: [0, 10 + j * 4],
            duration: 1800,
            delay: delay + j * 160,
            ease: 'out(2)'
          });
        });
      }

      var tl = null, visible = false, pending = false, out = false;
      function blow() {
        if (tl) tl.pause();
        out = true;
        tl = anime.createTimeline({
          onComplete: function () {
            tl = null;
            if (!loop) return;
            if (visible) relight(); else pending = true;
          }
        });
        tl.add(W, { x: [-160, 420], o: [{ from: 0, to: 1, duration: 260 }, { to: 1, duration: 300 }, { to: 0, duration: 340 }], duration: 900, ease: 'inOut(1.4)' });
        cs.forEach(function (c, i) {
          var at = 200 + i * (420 / cs.length);
          tl.add(c, { lean: [{ to: 38, duration: 180, ease: 'out(2)' }, { to: 46, duration: 160 }] }, at)
            .add(c, { lit: 0, duration: 260, ease: 'in(2)', onBegin: function () { puffSmoke(c, 160); } }, at + 120)
            .add(c, { ember: [{ from: 0, to: 1, duration: 60 }, { to: 0, duration: 900, ease: 'in(1.5)' }] }, at + 300);
        });
        if (loop) tl.add({ duration: 1500 });
      }
      function relight() {
        if (tl) tl.pause();
        out = false;
        cs.forEach(function (c) { c.lean = 0; });
        tl = anime.createTimeline({
          onComplete: function () {
            tl = null;
            if (visible) blow(); else pending = true;
          }
        });
        tl.add(cs, { lit: [0, 1], duration: 650, ease: 'outElastic(1, .55)', delay: anime.stagger(180) })
          .add({ duration: hold });
      }
      el._bdcToggle = function () { if (out) { loop ? relight() : relightOnly(); } else blow(); };
      function relightOnly() {
        if (tl) tl.pause();
        out = false;
        cs.forEach(function (c) { c.lean = 0; });
        tl = anime.animate(cs, { lit: [0, 1], duration: 650, ease: 'outElastic(1, .55)', delay: anime.stagger(180) });
      }

      frame(0);
      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) : 16;
          last = now;
          frame(dt);
        }
      });
      var started = false;
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (visible) { last = 0; timer.play(); if (tl) tl.play(); }
        else { timer.pause(); if (tl) tl.pause(); }
        if (visible && !started) {
          started = true;
          tl = anime.createTimeline({ onComplete: function () { tl = null; if (visible) blow(); else pending = true; } });
          tl.add({ duration: hold });
        } else if (visible && pending) { pending = false; out ? relight() : blow(); }
      }, { threshold: 0.25 }).observe(el);
    }

    window.animacious = window.animacious || {};
    window.animacious.birthdayCandles = function (el) { if (el && el._bdcToggle) el._bdcToggle(); };

    function init() {
      document.querySelectorAll('[data-birthday-candles]').forEach(function (el) {
        if (el._bdc) return;   // idempotent
        el._bdc = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-birthday-candles></div>

<!-- nine candles, white icing, again and again: -->
<div data-birthday-candles data-loop data-count="9" data-cake="#f8fafc"></div>