← Seasonal

Diya Lamps

A row of painted clay oil lamps. Their flames catch one after another, then flicker each on its own, and now and then a breath of air runs along the row and makes every flame lean in turn.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty <div data-diya-lamps> where the lamps should sit. They light up when it scrolls into view. It looks best on a dark background.

Options

Add any of these attributes to the same element.

data-countHow many lamps. Default 5.
data-colorClay colour. Default #c2410c.
data-flameFlame colour. Default #fbbf24.
data-speedFlicker speed multiplier. Default 1.

It is 8:5 with a transparent background by default; set your own height or background on the element. It pauses when off screen. With reduced motion the lamps are lit and steady.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Diya Lamps: a row of little clay oil lamps. Their flames catch one after
    // another, then flicker on their own, and now and then a breath of air runs
    // along the row and makes every flame lean in turn. Drawn as SVG that fills
    // the element, and paused when off screen.
    // Usage: <div data-diya-lamps></div>
    // It is 8:5 by default with a transparent background; it looks best on
    // something dark.
    // Optional: data-count (number of lamps, default 5), data-color (clay
    // colour, default #c2410c), data-flame (flame colour, default #fbbf24),
    // data-speed (flicker speed multiplier, default 1).
    var DEFAULTS = { count: 5, color: '#c2410c', flame: '#fbbf24', speed: 1 };
    var uid = 0;
    var DOTS = ['#facc15', '#f0abfc', '#5eead4', '#fde68a'];

    var css =
      '[data-diya-lamps]{display:block;position:relative;overflow:hidden;width:100%;aspect-ratio:8/5}' +
      '.dya-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; }

    // One diya centred on (0, 0), its wick at (40, -10).
    function diya(g, i) {
      var dots = '';
      for (var k = 0; k < 7; k++) {
        var a = -0.95 + k * 0.32;
        dots += '<circle cx="' + (Math.sin(a) * 27).toFixed(1) + '" cy="' + (8 + Math.cos(a) * 6).toFixed(1) + '" r="1.8" fill="' + DOTS[(k + i) % DOTS.length] + '"/>';
      }
      return '<ellipse cx="2" cy="30" rx="40" ry="5" fill="#000" opacity=".35"/>' +
        '<path d="M24 -6 Q36 -12 46 -13 Q42 -4 30 4 Z" fill="url(#' + g + '-clay)"/>' +
        '<path d="M-34 -3 C-33 15 -18 26 0 26 C18 26 31 15 34 -3 Z" fill="url(#' + g + '-clay)"/>' +
        '<path d="M-24 14 Q0 24 24 14" fill="none" stroke="#fde68a" stroke-opacity=".55" stroke-width="1.4" stroke-dasharray="4 3"/>' +
        dots +
        '<ellipse cx="0" cy="-3" rx="34" ry="7" fill="#7c2d12"/>' +
        '<ellipse cx="0" cy="-2" rx="29" ry="5" fill="#451a03"/>' +
        '<ellipse class="dya-oil" cx="3" cy="-1.6" rx="23" ry="3.4" fill="#f59e0b" opacity=".35"/>' +
        '<path d="M30 -5 Q36 -9 40 -10" stroke="#292524" stroke-width="2.2" stroke-linecap="round" fill="none"/>' +
        '<g class="dya-flame" transform="translate(40 -10) scale(0)">' +
          '<circle r="34" cy="-10" fill="url(#' + g + '-halo)"/>' +
          '<path d="M0 0 C-7 -3 -8 -13 0 -28 C8 -13 7 -3 0 0 Z" fill="url(#' + g + '-fire)"/>' +
          '<path d="M0 -1 C-3 -3 -3 -8 0 -14 C3 -8 3 -3 0 -1 Z" fill="#fffbeb"/>' +
          '<ellipse cx="0" cy="-2" rx="2.2" ry="2.8" fill="#60a5fa" opacity=".7"/>' +
        '</g>';
    }

    function build(el) {
      var d = el.dataset;
      var n = ++uid;
      var g = 'dya' + n;
      var count = Math.max(1, Math.min(12, d.count !== undefined ? Math.round(+d.count) : DEFAULTS.count));
      var clay = d.color || DEFAULTS.color;
      var flame = d.flame || DEFAULTS.flame;
      var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
      if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'A row of oil lamps with flickering flames');
      if (!el.hasAttribute('role')) el.setAttribute('role', 'img');

      var gap = 384 / count;
      var s = Math.min(1.3, gap / 82);
      var lamps = '';
      for (var i = 0; i < count; i++) {
        var x = 200 + (i - (count - 1) / 2) * gap;
        lamps += '<g class="dya-lamp" transform="translate(' + (x - 4 * s).toFixed(1) + ' 160) scale(' + s.toFixed(3) + ')">' + diya(g, i) + '</g>';
      }
      el.innerHTML =
        '<svg class="dya-svg" viewBox="0 0 400 250" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false">' +
        '<defs>' +
        '<radialGradient id="' + g + '-clay" cx=".4" cy=".2" r=".9"><stop offset="0" stop-color="#fb923c"/><stop offset=".45" stop-color="' + clay + '"/><stop offset="1" stop-color="#431407"/></radialGradient>' +
        '<radialGradient id="' + g + '-fire" cx=".5" cy=".8" r=".75"><stop offset="0" stop-color="#fff7d6"/><stop offset=".45" stop-color="' + flame + '"/><stop offset="1" stop-color="#ea580c"/></radialGradient>' +
        '<radialGradient id="' + g + '-halo"><stop offset="0" stop-color="' + flame + '" stop-opacity=".45"/><stop offset=".5" stop-color="#f97316" stop-opacity=".12"/><stop offset="1" stop-color="#f97316" stop-opacity="0"/></radialGradient>' +
        '<radialGradient id="' + g + '-pool"><stop offset="0" stop-color="#fdba74" stop-opacity=".35"/><stop offset="1" stop-color="#f97316" stop-opacity="0"/></radialGradient>' +
        '</defs>' +
        '<ellipse class="dya-pool" cx="200" cy="184" rx="210" ry="40" fill="url(#' + g + '-pool)" opacity="0"/>' +
        lamps +
        '</svg>';

      var svg = el.firstChild;
      var pool = svg.querySelector('.dya-pool');
      var flames = [];
      svg.querySelectorAll('.dya-flame').forEach(function (f, i) {
        flames.push({ el: f, lit: 0, f1: 6 + (i * 2.3) % 4, f2: 11 + (i * 3.7) % 6, ph: i * 2.1, oil: f.parentNode.querySelector('.dya-oil') });
      });

      // Gusts of air travel along the row: each flame feels them a little later.
      var t = 0, gusts = [], nextGust = 2500;
      function lean(time) {
        var sum = 0;
        for (var k = 0; k < gusts.length; k++) {
          var u = (time - gusts[k].at) / 900;
          if (u > 0 && u < 1) sum += gusts[k].power * Math.pow(Math.sin(Math.PI * u), 2);
        }
        return sum;
      }
      function frame(dt) {
        t += dt * speed;
        nextGust -= dt * speed;
        if (nextGust <= 0) {
          gusts.push({ at: t, power: (Math.random() < 0.5 ? -1 : 1) * (14 + Math.random() * 14) });
          if (gusts.length > 4) gusts.shift();
          nextGust = 2600 + Math.random() * 3600;
        }
        var s = t / 1000, total = 0;
        flames.forEach(function (fl, i) {
          var flick = 0.07 * Math.sin(s * fl.f1 + fl.ph) + 0.05 * Math.sin(s * fl.f2 + fl.ph * 2) + 0.03 * Math.sin(s * 29 + i);
          var gust = lean(t - i * 140);
          var sy = fl.lit * (1 + flick - Math.abs(gust) * 0.008);
          var sx = fl.lit * (1 - flick * 0.6);
          var skew = 3 * Math.sin(s * 2.1 + fl.ph) + gust;
          fl.el.setAttribute('transform', 'translate(40 -10) skewX(' + skew.toFixed(2) + ') scale(' + sx.toFixed(3) + ' ' + Math.max(0, sy).toFixed(3) + ')');
          fl.oil.setAttribute('opacity', (0.15 + fl.lit * (0.35 + flick)).toFixed(3));
          total += fl.lit * (0.9 + flick);
        });
        pool.setAttribute('opacity', (total / flames.length).toFixed(3));
      }

      if (reduced()) {
        flames.forEach(function (fl) { fl.lit = 1; });
        frame(0);
        return;
      }
      frame(0);

      var last = 0, started = false;
      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);
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) {
          last = 0;
          timer.play();
          if (!started) {
            started = true;
            // The flames catch one after another.
            anime.animate(flames, { lit: [0, 1], duration: 700, ease: 'outElastic(1, .55)', delay: anime.stagger(260, { start: 300 }) });
          }
        } else timer.pause();
      }, { threshold: 0.2 }).observe(el);
    }

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

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

<!-- seven lamps: -->
<div data-diya-lamps data-count="7"></div>