← Seasonal

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

  1. Paste the snippet just before </head>.
  2. Add data-fairy-lights to 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-patternchase (a wave runs along the string), twinkle (each bulb on its own) or alternate (odd and even bulbs take turns). Default chase.
data-colorsComma-separated bulb colours. Default red, yellow, green, blue and pink.
data-speedSpeed multiplier. Default 1.
data-spacingGap between bulbs along the wire. Default 34 px.
data-sagHow 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.

1 · Snippet
<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>
2 · Your HTML
<section data-fairy-lights>
  <h2>Happy holidays</h2>
</section>

<!-- warm white, twinkling: -->
<section data-fairy-lights data-pattern="twinkle" data-colors="#fff3c4,#ffe08a">…</section>