Fairy Lights
A string of coloured bulbs hangs in loose swags across the top of a section and twinkles in sequence, a wave of light running along the wire. Your content stays put underneath.
Happy holidays
How to use it
- Paste the snippet just before
</head>. - Add
data-fairy-lightsto any section. The string is drawn across its top edge, so leave a little room above your content.
Options
Add any of these attributes to the same element.
data-pattern | chase (a wave runs along the string), twinkle (each bulb on its own) or alternate (odd and even bulbs take turns). Default chase. |
data-colors | Comma-separated bulb colours. Default red, yellow, green, blue and pink. |
data-speed | Speed multiplier. Default 1. |
data-spacing | Gap between bulbs along the wire. Default 34 px. |
data-sag | How far each swag dips. Default 22 px. |
The string is re-laid when the section changes width, and pauses when off screen. With reduced motion every bulb stays softly lit.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Fairy Lights: a string of coloured bulbs hangs in loose swags across the
// top of an element and twinkles in sequence, a wave of light running along
// the wire. Your content stays where it is; the lights sit on top.
// Usage: <section data-fairy-lights>…</section>
// Optional: data-pattern ("chase" a wave runs along the string, default;
// "twinkle" each bulb on its own; "alternate" odd and even bulbs take turns),
// data-colors (comma-separated bulb colours), data-speed (default 1),
// data-spacing (px between bulbs, default 34), data-sag (how far each swag
// dips, px, default 22).
var DEFAULTS = {
pattern: 'chase',
colors: '#ff5a5f,#ffd23f,#3ddc84,#4da3ff,#ff7ad9',
speed: 1,
spacing: 34,
sag: 22
};
var NS = 'http://www.w3.org/2000/svg';
var uid = 0;
var css =
'[data-fairy-lights]{position:relative}' +
'.fyl-svg{position:absolute;left:0;top:0;width:100%;display:block;overflow:visible;pointer-events:none;z-index:1}';
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 pattern = d.pattern || DEFAULTS.pattern;
var colors = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); });
var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
var spacing = Math.max(14, +d.spacing || DEFAULTS.spacing);
var sag = d.sag !== undefined ? +d.sag : DEFAULTS.sag;
var svg = document.createElementNS(NS, 'svg');
svg.setAttribute('class', 'fyl-svg');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('focusable', 'false');
el.insertBefore(svg, el.firstChild);
// One soft halo gradient per colour, shared by every bulb of that colour.
var defs = '<defs>' + colors.map(function (c, i) {
return '<radialGradient id="fyl' + n + '-g' + i + '"><stop offset="0" stop-color="' + c + '" stop-opacity=".85"/>' +
'<stop offset=".35" stop-color="' + c + '" stop-opacity=".3"/><stop offset="1" stop-color="' + c + '" stop-opacity="0"/></radialGradient>';
}).join('') + '</defs>';
var bulbs = [], w = 0, t = 0;
// Lay the wire out as swags between pins, then hang bulbs along it at
// equal distances.
function layout() {
w = el.clientWidth;
if (!w) return;
var top = 6, h = sag + 34;
var swags = Math.max(1, Math.round(w / 190));
var span = w / swags;
var pts = [], wire = 'M0 ' + top;
for (var s = 0; s < swags; s++) {
var x0 = s * span, x1 = x0 + span;
wire += 'Q' + (x0 + span / 2).toFixed(1) + ' ' + (top + sag * 2) + ' ' + x1.toFixed(1) + ' ' + top;
for (var k = 0; k <= 60; k++) {
var u = k / 60;
pts.push([x0 + span * u, top + 2 * u * (1 - u) * sag * 2]);
}
}
// Walk along the wire, dropping a bulb every `spacing` px, centred
// so neither end is left bare.
var lens = [0];
for (var i = 1; i < pts.length; i++) {
lens.push(lens[i - 1] + Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]));
}
var total = lens[lens.length - 1];
var count = Math.max(1, Math.floor(total / spacing));
var dist = (total - (count - 1) * spacing) / 2, marks = [], j = 1;
for (var m = 0; m < count; m++, dist += spacing) {
while (j < lens.length - 1 && lens[j] < dist) j++;
var a = pts[j - 1], b = pts[j], f = (dist - lens[j - 1]) / ((lens[j] - lens[j - 1]) || 1);
marks.push([a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, Math.atan2(b[1] - a[1], b[0] - a[0])]);
}
var html = defs + '<path d="' + wire + '" fill="none" stroke="#1f3b2a" stroke-width="1.6"/>';
marks.forEach(function (m, i) {
var c = i % colors.length;
// Bulbs hang mostly straight down, tipped a little with the wire.
var tilt = (m[2] * 180 / Math.PI * 0.35).toFixed(1);
html +=
'<g transform="translate(' + m[0].toFixed(1) + ' ' + m[1].toFixed(1) + ') rotate(' + tilt + ')">' +
'<circle class="fyl-halo" cx="0" cy="11" r="17" fill="url(#fyl' + n + '-g' + c + ')" opacity="0"/>' +
'<rect x="-2.6" y="-1" width="5.2" height="6" rx="1.2" fill="#2d4a36"/>' +
'<path d="M0 4.5C4.6 4.5 5.6 10 4.6 13.4C3.8 16.4 1.6 18.5 0 18.5C-1.6 18.5-3.8 16.4-4.6 13.4C-5.6 10-4.6 4.5 0 4.5Z" fill="' + colors[c] + '" opacity=".38"/>' +
'<path class="fyl-lit" d="M0 4.5C4.6 4.5 5.6 10 4.6 13.4C3.8 16.4 1.6 18.5 0 18.5C-1.6 18.5-3.8 16.4-4.6 13.4C-5.6 10-4.6 4.5 0 4.5Z" fill="' + colors[c] + '" opacity="0"/>' +
'<ellipse class="fyl-shine" cx="-1.6" cy="10" rx="1.1" ry="2.6" fill="#fff" opacity="0"/>' +
'</g>';
});
svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
svg.setAttribute('height', h);
svg.innerHTML = html;
var groups = svg.querySelectorAll('g');
bulbs = [];
for (var b = 0; b < groups.length; b++) {
bulbs.push({
halo: groups[b].querySelector('.fyl-halo'),
lit: groups[b].querySelector('.fyl-lit'),
shine: groups[b].querySelector('.fyl-shine'),
rate: 0.6 + Math.random() * 0.9,
ph: Math.random() * 10
});
}
}
// How bright bulb i is at time t (seconds), 0 to 1.
function level(i, b) {
var s = t * speed;
if (pattern === 'twinkle') {
var v = Math.sin(s * 2.2 * b.rate + b.ph) * 0.6 + Math.sin(s * 5.3 * b.rate + b.ph * 2) * 0.4;
return Math.max(0.08, Math.min(1, 0.55 + v * 0.6));
}
if (pattern === 'alternate') {
var k = (Math.sin(s * 2.4) + 1) / 2;
k = k * k * (3 - 2 * k);
return 0.1 + 0.9 * (i % 2 ? k : 1 - k);
}
// Chase: bright heads with soft tails run along the string, lighting
// each bulb in turn, over a gently glowing base.
var gap = 8, phase = ((s * 7 - i) % gap + gap) % gap;
return 0.2 + 0.8 * Math.exp(-phase / 1.6);
}
function draw() {
for (var i = 0; i < bulbs.length; i++) {
var b = bulbs[i], v = level(i, b);
b.lit.setAttribute('opacity', v.toFixed(3));
b.halo.setAttribute('opacity', (v * v).toFixed(3));
b.shine.setAttribute('opacity', (v * 0.75).toFixed(3));
}
}
layout();
if (window.ResizeObserver) {
new ResizeObserver(function () {
if (el.clientWidth !== w) { layout(); draw(); }
}).observe(el);
}
if (reduced()) {
// Every bulb softly lit, no motion.
pattern = 'steady';
level = function () { return 0.85; };
draw();
return;
}
var last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
t += (last ? Math.min(50, now - last) : 16) / 1000;
last = now;
draw();
}
});
draw();
// Pause while off screen.
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-fairy-lights]').forEach(function (el) {
if (el._fyl) return; // idempotent
el._fyl = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section data-fairy-lights>
<h2>Happy holidays</h2>
</section>
<!-- warm white, twinkling: -->
<section data-fairy-lights data-pattern="twinkle" data-colors="#fff3c4,#ffe08a">…</section>