← Seasonal

Menorah Lighting

A Hanukkah menorah. The tall middle candle lights first, then a spark of its flame arcs over to each candle in turn and they light one by one, filling the scene with a warm glow.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty <div data-menorah-lighting> where the menorah should stand. The candles are lit when it scrolls into view. It looks best on a dark background.

Options

Add any of these attributes to the same element.

data-loopLet the candles go out and light them again.
data-nightsHow many candles, 1 to 8. They stand from the right, as on that night of Hanukkah. Default 8.
data-staggerTime between one candle and the next. Default 650 ms.
data-colorColour of the metal. Default gold #d4a017.
data-candleCandle colour. Default alternating blue and white.

It is 8:5 with a transparent background by default; set your own height or background on the element. With reduced motion every candle is shown lit.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Menorah Lighting: a Hanukkah menorah. The tall middle candle (the
    // shamash) lights first, then a spark of its flame arcs over to each
    // candle in turn and they light one by one, filling the scene with a warm
    // glow. Loop it and they go out and are lit again. Drawn as SVG that fills
    // the element.
    // Usage: <div data-menorah-lighting></div>
    // It is 8:5 by default with a transparent background; it looks best on
    // something dark.
    // Optional: data-loop (go out and light again), data-nights (how many
    // candles, 1 to 8, placed from the right as on each night of Hanukkah,
    // default 8), data-stagger (ms between candles, default 650),
    // data-color (menorah metal, default gold #d4a017), data-candle (candle
    // colour, default alternating blue and white).
    var DEFAULTS = { nights: 8, stagger: 650, color: '#d4a017' };
    var uid = 0;
    var CUP = 112, SHAMASH = 86, GAP = 38;

    var css =
      '[data-menorah-lighting]{display:block;position:relative;overflow:hidden;width:100%;aspect-ratio:8/5}' +
      '.mnr-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 flame(g) {
      return '<circle r="22" cy="-8" fill="url(#' + g + '-halo)"/>' +
        '<path d="M0 0 C-5 -2 -6 -10 0 -20 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"/>';
    }

    function build(el) {
      var d = el.dataset;
      var n = ++uid;
      var g = 'mnr' + n;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var nights = Math.max(1, Math.min(8, d.nights !== undefined ? Math.round(+d.nights) : DEFAULTS.nights));
      var stagger = d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger;
      var metal = d.color || DEFAULTS.color;
      var candleColor = d.candle;
      if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'Menorah candles light one by one');
      if (!el.hasAttribute('role')) el.setAttribute('role', 'img');

      // Branch positions, left to right; candles stand in the rightmost N.
      var xs = [];
      for (var i = 4; i >= 1; i--) xs.push(200 - i * GAP);
      for (i = 1; i <= 4; i++) xs.push(200 + i * GAP);
      var arms = '', cups = '', candles = '';
      for (i = 1; i <= 4; i++) {
        var r = i * GAP, ry = 18 + i * 16;
        arms += '<path d="M' + (200 - r) + ' ' + CUP + ' A' + r + ' ' + ry + ' 0 0 0 ' + (200 + r) + ' ' + CUP + '"/>';
      }
      var lit = [];
      xs.forEach(function (x, k) {
        cups += '<path d="M' + (x - 9) + ' ' + (CUP - 3) + ' L' + (x + 9) + ' ' + (CUP - 3) + ' L' + (x + 5) + ' ' + (CUP + 4) + ' L' + (x - 5) + ' ' + (CUP + 4) + ' Z" fill="url(#' + g + '-metal)"/>';
        if (k >= 8 - nights) {
          var c = candleColor || (k % 2 ? '#f8fafc' : '#93c5fd');
          candles += '<rect x="' + (x - 4.5) + '" y="' + (CUP - 37) + '" width="9" height="34" rx="2" fill="' + c + '"/>' +
            '<rect x="' + (x - 4.5) + '" y="' + (CUP - 37) + '" width="9" height="34" rx="2" fill="url(#' + g + '-round)"/>' +
            '<path d="M' + x + ' ' + (CUP - 37) + ' L' + x + ' ' + (CUP - 41) + '" stroke="#292524" stroke-width="1.5" stroke-linecap="round"/>' +
            '<g class="mnr-flame" data-x="' + x + '" data-y="' + (CUP - 40) + '">' + flame(g) + '</g>';
          lit.push(x);
        }
      });
      cups += '<path d="M190 ' + (SHAMASH - 3) + ' L210 ' + (SHAMASH - 3) + ' L205 ' + (SHAMASH + 5) + ' L195 ' + (SHAMASH + 5) + ' Z" fill="url(#' + g + '-metal)"/>';
      var shamash = '<rect x="195" y="' + (SHAMASH - 41) + '" width="10" height="38" rx="2" fill="' + (candleColor || '#fef3c7') + '"/>' +
        '<rect x="195" y="' + (SHAMASH - 41) + '" width="10" height="38" rx="2" fill="url(#' + g + '-round)"/>' +
        '<path d="M200 ' + (SHAMASH - 41) + ' L200 ' + (SHAMASH - 45) + '" stroke="#292524" stroke-width="1.5" stroke-linecap="round"/>' +
        '<g class="mnr-flame mnr-sham" data-x="200" data-y="' + (SHAMASH - 44) + '">' + flame(g) + '</g>';

      el.innerHTML =
        '<svg class="mnr-svg" viewBox="0 0 400 250" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false">' +
        '<defs>' +
        '<linearGradient id="' + g + '-metal" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fef3c7"/><stop offset=".35" stop-color="' + metal + '"/><stop offset="1" stop-color="#78350f"/></linearGradient>' +
        '<linearGradient id="' + g + '-round" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#000" stop-opacity=".18"/><stop offset=".4" stop-color="#fff" stop-opacity=".3"/><stop offset="1" stop-color="#000" stop-opacity=".22"/></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=".35"><stop offset="0" stop-color="#fbbf24" stop-opacity=".3"/><stop offset=".45" stop-color="#fbbf24" stop-opacity=".1"/><stop offset="1" stop-color="#fbbf24" stop-opacity="0"/></radialGradient>' +
        '</defs>' +
        '<ellipse class="mnr-warm" cx="200" cy="90" rx="220" ry="140" fill="url(#' + g + '-warm)" opacity="0"/>' +
        '<g fill="none" stroke="url(#' + g + '-metal)" stroke-width="5.5" stroke-linecap="round">' + arms + '</g>' +
        '<rect x="196" y="' + SHAMASH + '" width="8" height="128" fill="url(#' + g + '-metal)"/>' +
        '<circle cx="200" cy="' + (CUP + 18) + '" r="6" fill="url(#' + g + '-metal)"/>' +
        '<path d="M186 214 L214 214 L232 228 L168 228 Z" fill="url(#' + g + '-metal)"/>' +
        '<rect x="156" y="228" width="88" height="6" rx="3" fill="url(#' + g + '-metal)"/>' +
        cups + candles + shamash +
        '<g class="mnr-spark" opacity="0"><circle r="9" fill="url(#' + g + '-halo)"/><circle r="2.6" fill="#fffbeb"/></g>' +
        '</svg>';

      var svg = el.firstChild;
      var warm = svg.querySelector('.mnr-warm');
      var sparkEl = svg.querySelector('.mnr-spark');
      var F = [];
      svg.querySelectorAll('.mnr-flame').forEach(function (f, i) {
        F.push({ el: f, x: +f.getAttribute('data-x'), y: +f.getAttribute('data-y'), lit: 0, ph: i * 1.7, f1: 7 + (i * 1.3) % 3, f2: 12 + (i * 2.7) % 5 });
      });
      var sham = F.pop();   // the shamash is the last flame in the markup
      var SP = { u: 0, o: 0, fx: 200, fy: 0, tx: 0, ty: 0 };

      var t = 0;
      function render(dt) {
        t += dt || 0;
        var s = t / 1000, total = 0;
        F.concat([sham]).forEach(function (f) {
          var flick = 0.07 * Math.sin(s * f.f1 + f.ph) + 0.05 * Math.sin(s * f.f2 + f.ph * 2) + 0.03 * Math.sin(s * 29 + f.ph);
          var sc = Math.max(0, f.lit);
          f.el.setAttribute('transform', 'translate(' + f.x + ' ' + f.y + ') skewX(' + (2.5 * Math.sin(s * 2.2 + f.ph)).toFixed(2) + ') scale(' + (sc * (1 - flick * 0.5)).toFixed(3) + ' ' + (sc * (1 + flick)).toFixed(3) + ')');
          total += Math.min(1, sc);
        });
        warm.setAttribute('opacity', (total / (F.length + 1)).toFixed(3));
        if (SP.o > 0) {
          var u = SP.u, mx = (SP.fx + SP.tx) / 2, my = Math.min(SP.fy, SP.ty) - 34;
          var x = (1 - u) * (1 - u) * SP.fx + 2 * (1 - u) * u * mx + u * u * SP.tx;
          var y = (1 - u) * (1 - u) * SP.fy + 2 * (1 - u) * u * my + u * u * SP.ty;
          sparkEl.setAttribute('transform', 'translate(' + x.toFixed(1) + ' ' + y.toFixed(1) + ')');
        }
        sparkEl.setAttribute('opacity', SP.o.toFixed(2));
      }

      if (reduced()) {
        F.forEach(function (f) { f.lit = 1; });
        sham.lit = 1;
        render(0);
        return;
      }
      render(0);

      var tl = null, visible = false, pending = false;
      function light() {
        if (tl) tl.pause();
        tl = anime.createTimeline({
          onComplete: function () {
            tl = null;
            if (!loop) return;
            if (visible) light(); else pending = true;
          }
        });
        tl.add(sham, { lit: [0, 1], duration: 700, ease: 'outElastic(1, .55)' }, 300);
        var at = 1100;
        // Light from left to right, newest candle first.
        F.forEach(function (f) {
          tl.add(SP, {
            u: [0, 1], o: [{ from: 0, to: 1, duration: 80 }, { to: 1, duration: stagger * 0.7 - 160 }, { to: 0, duration: 80 }],
            duration: stagger * 0.7, ease: 'inOutSine',
            onBegin: function () { SP.fx = sham.x; SP.fy = sham.y - 12; SP.tx = f.x; SP.ty = f.y - 2; }
          }, at)
            .add(f, { lit: [0, 1], duration: 650, ease: 'outElastic(1, .55)' }, at + stagger * 0.7 - 40);
          at += stagger;
        });
        if (loop) {
          at += 2800;
          tl.add(F, { lit: 0, duration: 500, ease: 'in(2)', delay: anime.stagger(60) }, at)
            .add(sham, { lit: 0, duration: 500, ease: 'in(2)' }, at + F.length * 60 + 200)
            .add({ duration: 900 });
        }
      }

      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;
          render(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; light(); }
        else if (visible && pending) { pending = false; light(); }
      }, { threshold: 0.25 }).observe(el);
    }

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

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-menorah-lighting style="background: #0f172a"></div>

<!-- the third night, silver, again and again: -->
<div data-menorah-lighting data-loop data-nights="3" data-color="#cbd5e1"></div>