← Seasonal

Swaying Lanterns

Round paper lanterns hang on strings across the top of a section. Each one swings gently like a pendulum, its tassel trailing a beat behind, while the candle inside makes the paper glow and flicker.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty <div data-swaying-lanterns> where the lanterns should hang. The strings run up off its top edge, so it looks best at the top of a section.

Options

Add any of these attributes to the same element.

data-countHow many lanterns. Default fits the width, about one every 80 px.
data-colorPaper colour. Default red #dc2626.
data-glowCandlelight colour. Default #fde68a.
data-speedSway speed multiplier. Default 1.

It is 8:5 with a transparent background by default; set your own height or background on the element. The lanterns spread across its full width, are re-hung when it resizes, and pause when off screen. With reduced motion they hang still, softly lit.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Swaying Lanterns: round paper lanterns hang on strings from the top of the
    // element. Each one swings gently like a pendulum, its tassel trailing a
    // little behind, while the candle inside makes it glow and flicker. Drawn
    // as SVG that fills the element; the lanterns spread across its full width.
    // Usage: <div data-swaying-lanterns></div>
    // It is 8:5 by default with a transparent background; it looks best on
    // something dark.
    // Optional: data-count (number of lanterns, default fits the width),
    // data-color (paper colour, default #dc2626), data-glow (candlelight
    // colour, default #fde68a), data-speed (sway speed multiplier, default 1).
    var DEFAULTS = { color: '#dc2626', glow: '#fde68a', speed: 1 };
    var H = 250;   // viewBox height; the width follows the element
    var uid = 0;

    var css =
      '[data-swaying-lanterns]{display:block;position:relative;overflow:hidden;width:100%;aspect-ratio:8/5}' +
      '.swl-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 lantern hanging from (0, 0) on a string of length len, size s.
    function lantern(g, len, s) {
      var rx = 30 * s, ry = 25 * s, cy = len + 6 * s + ry;
      var bottom = cy + ry;
      return '<g class="swl-lantern">' +
        '<path d="M0 -400 L0 ' + len + '" stroke="#1c1917" stroke-opacity=".7" stroke-width="1.2"/>' +
        '<circle class="swl-halo" cx="0" cy="' + cy + '" r="' + (rx * 2.6) + '" fill="url(#' + g + '-halo)"/>' +
        '<rect x="' + (-11 * s) + '" y="' + len + '" width="' + (22 * s) + '" height="' + (7 * s) + '" rx="' + (2 * s) + '" fill="#b45309"/>' +
        '<ellipse cx="0" cy="' + cy + '" rx="' + rx + '" ry="' + ry + '" fill="url(#' + g + '-paper)"/>' +
        '<ellipse class="swl-light" cx="0" cy="' + cy + '" rx="' + rx + '" ry="' + ry + '" fill="url(#' + g + '-light)"/>' +
        // Ribs of the paper frame.
        '<g fill="none" stroke="#450a0a" stroke-opacity=".35" stroke-width="1">' +
          '<ellipse cx="0" cy="' + cy + '" rx="' + (rx * 0.66) + '" ry="' + ry + '"/>' +
          '<ellipse cx="0" cy="' + cy + '" rx="' + (rx * 0.3) + '" ry="' + ry + '"/>' +
          '<path d="M0 ' + (cy - ry) + ' L0 ' + bottom + '"/>' +
        '</g>' +
        '<rect x="' + (-11 * s) + '" y="' + (bottom - 3 * s) + '" width="' + (22 * s) + '" height="' + (7 * s) + '" rx="' + (2 * s) + '" fill="#b45309"/>' +
        '<g class="swl-tassel" data-top="' + (bottom + 4 * s) + '">' +
          '<path d="M0 ' + (bottom + 4 * s) + ' L0 ' + (bottom + 10 * s) + '" stroke="#f59e0b" stroke-width="1.6"/>' +
          '<circle cx="0" cy="' + (bottom + 11 * s) + '" r="' + (2.6 * s) + '" fill="#f59e0b"/>' +
          '<path d="M' + (-4 * s) + ' ' + (bottom + 12 * s) + ' L' + (-5 * s) + ' ' + (bottom + 34 * s) + ' L' + (5 * s) + ' ' + (bottom + 34 * s) + ' L' + (4 * s) + ' ' + (bottom + 12 * s) + ' Z" fill="url(#' + g + '-tassel)"/>' +
        '</g>' +
        '</g>';
    }

    function build(el) {
      var d = el.dataset;
      var n = ++uid;
      var g = 'swl' + n;
      var color = d.color || DEFAULTS.color;
      var glow = d.glow || DEFAULTS.glow;
      var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
      var fixedCount = d.count !== undefined ? Math.max(1, Math.round(+d.count)) : 0;
      if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'Paper lanterns swaying and glowing');
      if (!el.hasAttribute('role')) el.setAttribute('role', 'img');

      var svg, items = [], W = 0;

      function layout() {
        var w = el.clientWidth, h = el.clientHeight;
        if (!w || !h) return;
        var newW = Math.max(200, Math.round(H * w / h));
        if (Math.abs(newW - W) < 4 && svg) return;
        W = newW;
        var count = fixedCount || Math.max(3, Math.min(9, Math.round(W / 80)));
        var html = '';
        var lens = [46, 92, 30, 74, 56, 104, 38, 84, 62];
        for (var i = 0; i < count; i++) {
          var x = W * (i + 0.5) / count + (i % 2 ? 6 : -6);
          var len = lens[i % lens.length] * (count < 4 ? 0.9 : 1);
          var s = (i % 3 === 1 ? 0.82 : 1) * Math.min(1.1, Math.max(0.7, W / count / 82));
          html += '<g class="swl-pivot" transform="translate(' + x.toFixed(1) + ' 0)" data-len="' + (len + 40 * s) + '">' + lantern(g, len, s) + '</g>';
        }
        el.innerHTML =
          '<svg class="swl-svg" viewBox="0 0 ' + W + ' ' + H + '" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false">' +
          '<defs>' +
          '<radialGradient id="' + g + '-paper" cx=".42" cy=".4" r=".7"><stop offset="0" stop-color="' + color + '"/><stop offset=".75" stop-color="' + color + '"/><stop offset="1" stop-color="#450a0a"/></radialGradient>' +
          '<radialGradient id="' + g + '-light" cx=".5" cy=".55" r=".6"><stop offset="0" stop-color="' + glow + '" stop-opacity=".95"/><stop offset=".5" stop-color="' + glow + '" stop-opacity=".35"/><stop offset="1" stop-color="' + glow + '" stop-opacity="0"/></radialGradient>' +
          '<radialGradient id="' + g + '-halo"><stop offset="0" stop-color="' + glow + '" stop-opacity=".4"/><stop offset=".45" stop-color="#f97316" stop-opacity=".12"/><stop offset="1" stop-color="#f97316" stop-opacity="0"/></radialGradient>' +
          '<linearGradient id="' + g + '-tassel" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f59e0b"/><stop offset="1" stop-color="' + color + '"/></linearGradient>' +
          '</defs>' + html + '</svg>';
        svg = el.firstChild;
        items = [];
        svg.querySelectorAll('.swl-pivot').forEach(function (p, i) {
          var len = +p.getAttribute('data-len');
          var tassel = p.querySelector('.swl-tassel');
          items.push({
            el: p,
            x: p.transform.baseVal.getItem(0).matrix.e,
            // A longer string swings more slowly, like a real pendulum.
            period: 2600 * Math.sqrt(len / 110),
            phase: i * 1.7,
            amp: 5 + (i % 3) * 1.6,
            tassel: tassel,
            ttop: +tassel.getAttribute('data-top'),
            light: p.querySelector('.swl-light'),
            halo: p.querySelector('.swl-halo'),
            f: 3 + i * 0.9
          });
        });
        frame(0);
      }

      var t = 0;
      function frame(dt) {
        t += dt * speed;
        var s = t / 1000;
        var breeze = 0.75 + 0.25 * Math.sin(s * 0.37);
        items.forEach(function (it) {
          var w = 2 * Math.PI * t / it.period + it.phase;
          var a = it.amp * breeze * Math.sin(w);
          it.el.setAttribute('transform', 'translate(' + it.x.toFixed(1) + ' 0) rotate(' + a.toFixed(2) + ')');
          // The tassel lags behind the swing.
          var lag = it.amp * 0.7 * breeze * Math.sin(w - 0.9) - a;
          it.tassel.setAttribute('transform', 'rotate(' + lag.toFixed(2) + ' 0 ' + it.ttop.toFixed(1) + ')');
          var v = 0.82 + 0.08 * Math.sin(s * it.f) + 0.06 * Math.sin(s * it.f * 2.3 + 1) + 0.04 * Math.sin(s * 17 + it.phase);
          it.light.setAttribute('opacity', v.toFixed(3));
          it.halo.setAttribute('opacity', (v * v).toFixed(3));
        });
      }

      layout();
      if (window.ResizeObserver) new ResizeObserver(layout).observe(el);
      if (reduced()) return;

      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);
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

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

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

<!-- a short, wide strip of gold lanterns: -->
<div data-swaying-lanterns data-color="#f59e0b" style="height: 200px"></div>