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
- Paste the snippet just before
</head>. - 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-count | How many lanterns. Default fits the width, about one every 80 px. |
data-color | Paper colour. Default red #dc2626. |
data-glow | Candlelight colour. Default #fde68a. |
data-speed | Sway 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.
<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><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>