← Seasonal

Twinkling Tree

A Christmas tree grows up tier by tier, baubles pop on and the star lands on top. Then the baubles twinkle, each on its own rhythm, while the star glows and sends out slow-turning rays.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty <div data-twinkling-tree> where the tree should go. It builds itself when it scrolls into view, then keeps twinkling.

Options

Add any of these attributes to the same element.

data-colorsComma-separated bauble colours. Default red, yellow, blue, pink and violet.
data-speedTwinkle speed multiplier. Default 1.
data-gifts="false"No presents under the tree.

It is 8:5 with a transparent background by default; set your own height or background on the element. The twinkling pauses when off screen. With reduced motion the tree is shown finished and lit.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Twinkling Tree: a Christmas tree grows up tier by tier, baubles pop on
    // and the star lands on top. Then the baubles twinkle, each on its own
    // rhythm, and the star glows and sends out slow-turning rays. Drawn as SVG
    // that fills the element, and paused when off screen.
    // Usage: <div data-twinkling-tree></div>
    // It is 8:5 by default with a transparent background; give it your own
    // height or background to change that.
    // Optional: data-colors (comma-separated bauble colours), data-speed
    // (twinkle speed multiplier, default 1), data-gifts="false" (no presents
    // under the tree).
    var DEFAULTS = { colors: '#ef4444,#facc15,#38bdf8,#f472b6,#a78bfa', speed: 1 };
    var uid = 0;

    var css =
      '[data-twinkling-tree]{display:block;position:relative;width:100%;aspect-ratio:8/5}' +
      '.twt-svg{position:absolute;inset:0;width:100%;height:100%;display:block;overflow:visible}' +
      '.twt-tier,.twt-gift,.twt-trunk{transform-box:fill-box;transform-origin:50% 100%}' +
      '.twt-ball,.twt-star{transform-box:fill-box;transform-origin:50% 50%}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    // Where the baubles hang, in viewBox units.
    var BALLS = [
      [200, 80], [189, 101], [213, 104],
      [212, 120], [172, 141], [197, 132], [228, 145],
      [189, 160], [222, 163], [150, 187], [176, 178], [204, 190], [234, 180], [258, 191]
    ];

    // A tier of the tree: a soft triangle with a scalloped hem.
    function tier(top, base, half) {
      var l = 200 - half, r = 200 + half, k = half / 3;
      return 'M200 ' + top + ' Q' + (200 - half * 0.35) + ' ' + (top + (base - top) * 0.55) + ' ' + l + ' ' + base +
        ' Q' + (l + k * 0.5) + ' ' + (base + 7) + ' ' + (l + k) + ' ' + (base + 1) +
        ' Q' + (l + k * 1.5) + ' ' + (base + 8) + ' ' + (l + k * 2) + ' ' + (base + 2) +
        ' Q' + (200 - k * 0.5) + ' ' + (base + 9) + ' 200 ' + (base + 3) +
        ' Q' + (200 + k * 0.5) + ' ' + (base + 9) + ' ' + (r - k * 2) + ' ' + (base + 2) +
        ' Q' + (r - k * 1.5) + ' ' + (base + 8) + ' ' + (r - k) + ' ' + (base + 1) +
        ' Q' + (r - k * 0.5) + ' ' + (base + 7) + ' ' + r + ' ' + base +
        ' Q' + (200 + half * 0.35) + ' ' + (top + (base - top) * 0.55) + ' 200 ' + top + 'Z';
    }

    function starPath(cx, cy, ro, ri) {
      var d = '';
      for (var i = 0; i < 10; i++) {
        var a = -Math.PI / 2 + i * Math.PI / 5, r = i % 2 ? ri : ro;
        d += (i ? 'L' : 'M') + (cx + Math.cos(a) * r).toFixed(2) + ' ' + (cy + Math.sin(a) * r).toFixed(2);
      }
      return d + 'Z';
    }

    function build(el) {
      var d = el.dataset;
      var n = ++uid;
      var colors = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); });
      var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
      var gifts = d.gifts !== 'false';
      if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'A Christmas tree with twinkling baubles and a glowing star');
      if (!el.hasAttribute('role')) el.setAttribute('role', 'img');

      var g = 'twt' + n;
      var defs = '<defs>' +
        '<linearGradient id="' + g + '-green" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#15803d"/><stop offset=".55" stop-color="#16a34a"/><stop offset="1" stop-color="#166534"/></linearGradient>' +
        '<radialGradient id="' + g + '-glow"><stop offset="0" stop-color="#fde68a" stop-opacity=".9"/><stop offset=".4" stop-color="#facc15" stop-opacity=".3"/><stop offset="1" stop-color="#facc15" stop-opacity="0"/></radialGradient>' +
        '<linearGradient id="' + g + '-ray" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fef9c3" stop-opacity="0"/><stop offset="1" stop-color="#fef9c3" stop-opacity=".9"/></linearGradient>' +
        colors.map(function (c, i) {
          return '<radialGradient id="' + g + '-b' + i + '"><stop offset="0" stop-color="' + c + '" stop-opacity=".9"/><stop offset=".4" stop-color="' + c + '" stop-opacity=".35"/><stop offset="1" stop-color="' + c + '" stop-opacity="0"/></radialGradient>';
        }).join('') +
        '</defs>';

      var balls = BALLS.map(function (p, i) {
        var c = i % colors.length;
        return '<g class="twt-ball">' +
          '<circle class="twt-halo" cx="' + p[0] + '" cy="' + p[1] + '" r="15" fill="url(#' + g + '-b' + c + ')" opacity="0"/>' +
          '<circle cx="' + p[0] + '" cy="' + p[1] + '" r="5.4" fill="' + colors[c] + '"/>' +
          '<circle class="twt-lit" cx="' + p[0] + '" cy="' + p[1] + '" r="5.4" fill="#fff" opacity="0"/>' +
          '<circle cx="' + (p[0] - 1.8) + '" cy="' + (p[1] - 1.9) + '" r="1.5" fill="#fff" opacity=".8"/>' +
          '</g>';
      }).join('');

      var rays = '';
      for (var i = 0; i < 8; i++) {
        var len = i % 2 ? 22 : 34;
        rays += '<path d="M-1.6 -10 L0 -' + len + ' L1.6 -10Z" fill="url(#' + g + '-ray)" transform="rotate(' + (i * 45) + ')"/>';
      }

      el.innerHTML =
        '<svg class="twt-svg" viewBox="0 0 400 250" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false">' +
        defs +
        '<ellipse cx="200" cy="226" rx="120" ry="9" fill="#000" opacity=".16"/>' +
        '<rect class="twt-trunk" x="191" y="200" width="18" height="24" rx="2" fill="#7c4a1e"/>' +
        '<path class="twt-tier" d="' + tier(122, 200, 86) + '" fill="url(#' + g + '-green)"/>' +
        '<path class="twt-tier" d="' + tier(82, 152, 64) + '" fill="url(#' + g + '-green)"/>' +
        '<path class="twt-tier" d="' + tier(48, 108, 42) + '" fill="url(#' + g + '-green)"/>' +
        '<g class="twt-garland" fill="none" stroke="#fcd34d" stroke-width="1.6" stroke-linecap="round" stroke-dasharray="0.1 4.2">' +
          '<path d="M178 92 Q200 108 224 98"/><path d="M152 128 Q196 158 250 136"/><path d="M128 168 Q200 206 276 174"/>' +
        '</g>' +
        balls +
        (gifts ?
          '<g class="twt-gift"><rect x="138" y="204" width="30" height="22" rx="2" fill="#dc2626"/><rect x="150" y="204" width="6" height="22" fill="#fde68a"/><rect x="136" y="200" width="34" height="7" rx="1.5" fill="#b91c1c"/><path d="M153 200 q-9 -9 -10 -1z M153 200 q9 -9 10 -1z" fill="#fde68a"/></g>' +
          '<g class="twt-gift"><rect x="236" y="210" width="22" height="16" rx="2" fill="#2563eb"/><rect x="245" y="210" width="5" height="16" fill="#f8fafc"/><rect x="234" y="207" width="26" height="5" rx="1.5" fill="#1d4ed8"/></g>' +
          '<g class="twt-gift"><rect x="216" y="216" width="16" height="10" rx="1.5" fill="#a855f7"/><rect x="222" y="216" width="4" height="10" fill="#fde68a"/></g>'
          : '') +
        '<g transform="translate(200 44)">' +
          '<circle class="twt-glow" r="34" fill="url(#' + g + '-glow)" opacity="0"/>' +
          '<g class="twt-rays" opacity="0">' + rays + '</g>' +
          '<path class="twt-star" d="' + starPath(0, 0, 13, 5.6) + '" fill="#facc15" stroke="#f59e0b" stroke-width="1" stroke-linejoin="round"/>' +
        '</g>' +
        '</svg>';

      var svg = el.firstChild;
      var halos = svg.querySelectorAll('.twt-halo');
      var lits = svg.querySelectorAll('.twt-lit');
      var glow = svg.querySelector('.twt-glow');
      var raysG = svg.querySelector('.twt-rays');
      var phase = BALLS.map(function () { return { r: 0.6 + Math.random() * 0.9, p: Math.random() * 10 }; });

      var t = 0, lightsOn = 0;
      function draw() {
        var s = t * speed / 1000;
        for (var i = 0; i < halos.length; i++) {
          var q = phase[i];
          var v = 0.5 + 0.5 * Math.sin(s * 2.6 * q.r + q.p);
          v = Math.pow(v, 3) * lightsOn;   // mostly calm, with bright moments
          halos[i].setAttribute('opacity', v.toFixed(3));
          lits[i].setAttribute('opacity', (v * 0.45).toFixed(3));
        }
        var pulse = 0.75 + 0.25 * Math.sin(s * 2.2);
        glow.setAttribute('opacity', (pulse * lightsOn).toFixed(3));
        raysG.setAttribute('opacity', ((0.55 + 0.35 * Math.sin(s * 1.7)) * lightsOn).toFixed(3));
        raysG.setAttribute('transform', 'rotate(' + (s * 12 % 360).toFixed(2) + ') scale(' + (0.9 + 0.12 * pulse) + ')');
      }

      if (reduced()) {
        lightsOn = 1; t = 400;
        draw();
        return;
      }

      var tiers = [].slice.call(svg.querySelectorAll('.twt-tier')).reverse();
      var hidden = svg.querySelectorAll('.twt-tier,.twt-trunk,.twt-ball,.twt-gift,.twt-star');
      anime.utils.set(hidden, { scale: 0 });
      anime.utils.set(svg.querySelector('.twt-garland'), { opacity: 0 });

      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;
          last = now;
          draw();
        }
      });

      function intro() {
        var tl = anime.createTimeline();
        tl.add(svg.querySelector('.twt-trunk'), { scale: [0, 1], duration: 350, ease: 'out(3)' })
          .add(tiers, { scale: [0, 1], duration: 650, delay: anime.stagger(170), ease: 'outBack(1.6)' }, '-=150')
          .add(svg.querySelector('.twt-garland'), { opacity: [0, 1], duration: 500 }, '-=200')
          .add(svg.querySelectorAll('.twt-ball'), { scale: [0, 1], duration: 450, delay: anime.stagger(45), ease: 'outBack(2.4)' }, '-=400')
          .add(svg.querySelectorAll('.twt-gift'), { scale: [0, 1], duration: 450, delay: anime.stagger(90), ease: 'outBack(2)' }, '-=500')
          .add(svg.querySelector('.twt-star'), { scale: [0, 1], rotate: [-216, 0], duration: 800, ease: 'outBack(1.8)' }, '-=300')
          .add({ duration: 900, onUpdate: function (a) { lightsOn = a.progress; } }, '-=350');
      }

      var started = false;
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) {
          if (!started) { started = true; intro(); }
          last = 0; timer.play();
        } else timer.pause();
      }, { threshold: 0.2 }).observe(el);
    }

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

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-twinkling-tree></div>

<!-- gold and silver baubles, on a dark panel: -->
<div data-twinkling-tree data-colors="#facc15,#e2e8f0" style="background: #0f172a"></div>