← Seasonal

Snow Globe

A little winter village under glass. Tap it and the globe rocks, the snow swirls up round the house, then drifts slowly down and settles on the ground again.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty <div data-snow-globe> where the globe should go. It shakes itself once when it scrolls into view; after that, a click, tap or Enter shakes it.

Options

Add any of these attributes to the same element.

data-autoplayShake again every few seconds while nobody touches it.
data-intervalTime between autoplay shakes. Default 6500 ms.
data-flakesNumber of snowflakes. Default 90.
data-colorColour of the base. Default deep red #b91c1c.

It is 8:5 by default with a transparent background; set your own height on the element to change that. animacious.snowGlobe(el) shakes it from script. With reduced motion the snow stays settled.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Snow Globe: a little winter village under glass. Tap or click it (or
    // press Enter when focused) and the globe rocks, the snow swirls up round
    // the house and then drifts slowly down and settles on the ground again.
    // It gives itself one shake when it first scrolls into view.
    // Usage: <div data-snow-globe></div>
    // It is 8:5 by default with a transparent background; give it your own
    // height or background to change that.
    // Optional: data-flakes (number of flakes, default 90), data-autoplay
    // (shake again every few seconds while left alone), data-interval (ms
    // between autoplay shakes, default 6500), data-color (base colour,
    // default deep red #b91c1c).
    // Script use: animacious.snowGlobe(el) shakes it.
    var DEFAULTS = { flakes: 90, interval: 6500, color: '#b91c1c' };
    var NS = 'http://www.w3.org/2000/svg';
    var uid = 0;
    var CX = 100, CY = 96, R = 76;   // the inside of the glass, in viewBox units

    var css =
      '[data-snow-globe]{display:block;position:relative;width:100%;aspect-ratio:8/5;-webkit-tap-highlight-color:transparent}' +
      '.sgb-svg{position:absolute;inset:0;width:100%;height:100%;display:block;overflow:visible}' +
      '.sgb-body{transform-box:view-box;transform-origin:100px 214px;cursor:pointer}' +
      '[data-snow-globe]:focus-visible{outline:2px solid currentColor;outline-offset:4px;border-radius:12px}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    // The top of the snowy ground inside the globe at x.
    function ground(x) { var u = (x - CX) / 70; return 147 + u * u * 16; }

    function build(el) {
      var d = el.dataset;
      var n = ++uid;
      var count = Math.max(0, +d.flakes || DEFAULTS.flakes);
      var interval = +d.interval || DEFAULTS.interval;
      var base = d.color || DEFAULTS.color;
      var inThumb = !!el.closest('[aria-hidden="true"]');
      if (!inThumb) {
        if (!el.hasAttribute('role')) el.setAttribute('role', 'button');
        if (!el.hasAttribute('tabindex')) el.tabIndex = 0;
        if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'Snow globe: shake it');
      }

      var g = 'sgb' + n;
      el.innerHTML =
        '<svg class="sgb-svg" viewBox="0 0 200 240" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false">' +
        '<defs>' +
        '<clipPath id="' + g + '-clip"><circle cx="' + CX + '" cy="' + CY + '" r="' + R + '"/></clipPath>' +
        '<radialGradient id="' + g + '-sky" cx=".5" cy=".35" r=".75"><stop offset="0" stop-color="#3b5b9a"/><stop offset="1" stop-color="#14213d"/></radialGradient>' +
        '<radialGradient id="' + g + '-glass" cx=".32" cy=".26" r=".8"><stop offset="0" stop-color="#fff" stop-opacity=".28"/><stop offset=".45" stop-color="#fff" stop-opacity=".04"/><stop offset=".9" stop-color="#cfe3ff" stop-opacity=".08"/><stop offset="1" stop-color="#cfe3ff" stop-opacity=".35"/></radialGradient>' +
        '<linearGradient id="' + g + '-base" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#000" stop-opacity=".35"/><stop offset=".4" stop-color="#fff" stop-opacity=".12"/><stop offset="1" stop-color="#000" stop-opacity=".4"/></linearGradient>' +
        '</defs>' +
        '<ellipse cx="100" cy="218" rx="74" ry="7" fill="#000" opacity=".22"/>' +
        '<g class="sgb-body">' +
          // Base: a flared plinth with a gold band.
          '<path d="M44 166 L156 166 L170 212 Q100 220 30 212 Z" fill="' + base + '"/>' +
          '<path d="M44 166 L156 166 L170 212 Q100 220 30 212 Z" fill="url(#' + g + '-base)"/>' +
          '<path d="M38 188 Q100 194 162 188 L164 196 Q100 202 36 196 Z" fill="#e8b84a"/>' +
          '<ellipse cx="100" cy="166" rx="56" ry="7" fill="#7f1d1d"/>' +
          // Inside the glass.
          '<g clip-path="url(#' + g + '-clip)">' +
            '<rect x="20" y="16" width="160" height="160" fill="url(#' + g + '-sky)"/>' +
            '<circle cx="140" cy="54" r="9" fill="#fef9c3" opacity=".9"/>' +
            '<path d="M24 150 Q60 128 100 142 Q140 128 176 150 L176 180 L24 180Z" fill="#dbe7f5"/>' +
            // Pines.
            '<g fill="#1f6f4a"><path d="M52 146 L40 146 L48 132 L42 132 L52 112 L62 132 L56 132 L64 146 Z"/><rect x="50" y="146" width="4" height="5" fill="#5b3a1e"/></g>' +
            '<g fill="#17573a"><path d="M150 148 L138 148 L146 136 L141 136 L150 118 L159 136 L154 136 L162 148 Z"/><rect x="148" y="148" width="4" height="5" fill="#5b3a1e"/></g>' +
            // The house.
            '<rect x="80" y="118" width="40" height="30" fill="#c2410c"/>' +
            '<path d="M74 120 L100 98 L126 120 Z" fill="#7c2d12"/>' +
            '<path d="M73 121 L100 97 L127 121 L122 122 L100 103 L78 122 Z" fill="#f8fafc"/>' +
            '<rect x="108" y="102" width="6" height="12" fill="#57534e"/><rect x="107" y="100" width="8" height="3" fill="#f8fafc"/>' +
            '<rect x="85" y="126" width="10" height="9" fill="#fde68a"/><path d="M90 126V135M85 130.5H95" stroke="#c2410c" stroke-width="1"/>' +
            '<rect x="102" y="130" width="10" height="18" fill="#451a03"/>' +
            '<path d="M24 151 Q60 138 100 148 Q140 138 176 151 L176 180 L24 180Z" fill="#f1f5f9"/>' +
            '<g class="sgb-flakes"></g>' +
          '</g>' +
          // Glass sheen and rim.
          '<circle cx="' + CX + '" cy="' + CY + '" r="' + R + '" fill="url(#' + g + '-glass)" stroke="#e0ecff" stroke-opacity=".55" stroke-width="2"/>' +
          '<path d="M52 52 Q66 32 92 26" fill="none" stroke="#fff" stroke-opacity=".55" stroke-width="4" stroke-linecap="round"/>' +
          '<path d="M46 66 Q47 62 49 59" fill="none" stroke="#fff" stroke-opacity=".45" stroke-width="3" stroke-linecap="round"/>' +
        '</g>' +
        '</svg>';

      var svg = el.firstChild;
      var body = svg.querySelector('.sgb-body');
      var flakesG = svg.querySelector('.sgb-flakes');

      // Every flake starts settled on the ground.
      var flakes = [];
      for (var i = 0; i < count; i++) {
        var c = document.createElementNS(NS, 'circle');
        var r = 0.7 + Math.random() * 1.5;
        c.setAttribute('r', r.toFixed(2));
        c.setAttribute('fill', '#fff');
        c.setAttribute('opacity', (0.7 + Math.random() * 0.3).toFixed(2));
        flakesG.appendChild(c);
        var x = CX + (Math.random() * 2 - 1) * 64;
        flakes.push({ el: c, x: x, y: ground(x) - Math.random() * 3, vx: 0, vy: 0, rest: true, ph: Math.random() * 6.28, pull: 0.45 + Math.random() * 0.9, fall: 0.6 + Math.random() * 0.8 });
      }

      var swirl = 0, spin = 1, running = false, t = 0;

      function place() {
        for (var i = 0; i < flakes.length; i++) {
          flakes[i].el.setAttribute('cx', flakes[i].x.toFixed(1));
          flakes[i].el.setAttribute('cy', flakes[i].y.toFixed(1));
        }
      }

      function step(dt) {
        var s = dt / 1000;
        t += s;
        swirl *= Math.exp(-s / 1.4);   // the swirl dies away over a second or two
        var moving = false;
        for (var i = 0; i < flakes.length; i++) {
          var f = flakes[i];
          if (f.rest) continue;
          moving = true;
          var dx = f.x - CX, dy = f.y - CY, dist = Math.sqrt(dx * dx + dy * dy) || 1;
          // Swirl: a push round the centre, plus a little lift and turbulence.
          var sw = swirl * f.pull * spin;
          f.vx += (-dy / dist * sw + Math.sin(t * 2.3 + f.ph) * 14) * s;
          f.vy += (dx / dist * sw - swirl * 0.25 + Math.cos(t * 1.9 + f.ph) * 8 + 16 * f.fall) * s;
          // Water drag: flakes drift rather than drop.
          var drag = Math.exp(-s * 2.2);
          f.vx *= drag; f.vy *= drag;
          f.x += f.vx * s; f.y += f.vy * s;
          // Keep them inside the glass.
          dx = f.x - CX; dy = f.y - CY; dist = Math.sqrt(dx * dx + dy * dy);
          if (dist > R - 3) {
            f.x = CX + dx / dist * (R - 3); f.y = CY + dy / dist * (R - 3);
            f.vx *= 0.5; f.vy *= 0.5;
          }
          // Landed: settle once the swirl has calmed.
          if (f.y >= ground(f.x) - 1 && f.vy > 0 && swirl < 40) {
            f.y = ground(f.x) - Math.random() * 3;
            f.rest = true;
          }
        }
        place();
        return moving;
      }

      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;
          if (!step(dt)) { running = false; timer.pause(); }
        }
      });

      function shake() {
        if (reduced()) return;
        spin = Math.random() < 0.5 ? -1 : 1;
        swirl = 150;
        flakes.forEach(function (f) {
          f.rest = false;
          f.vx = (Math.random() * 2 - 1) * 120;
          f.vy = -30 - Math.random() * 150;
        });
        anime.animate(body, {
          rotate: [0, -7 * spin, 6 * spin, -4 * spin, 2 * spin, 0],
          x: [0, -5 * spin, 4 * spin, -2 * spin, 0],
          duration: 900,
          ease: 'inOutSine'
        });
        if (!running && visible) { running = true; last = 0; timer.play(); }
      }

      place();
      if (reduced()) return;

      var visible = false, shookOnce = false, lastTouch = 0;
      el.addEventListener('click', function () { lastTouch = performance.now(); shake(); });
      el.addEventListener('keydown', function (e) {
        if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); lastTouch = performance.now(); shake(); }
      });

      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (visible) {
          if (!shookOnce) { shookOnce = true; setTimeout(shake, 300); }
          else if (running) { last = 0; timer.play(); }
        } else timer.pause();
      }, { threshold: 0.2 }).observe(el);

      if (d.autoplay !== undefined && d.autoplay !== 'false') {
        // Shake again now and then, unless someone has just shaken it.
        setInterval(function () {
          if (visible && performance.now() - lastTouch > interval) shake();
        }, interval);
      }

      el._sgbShake = shake;
    }

    window.animacious = window.animacious || {};
    window.animacious.snowGlobe = function (el) { if (el && el._sgbShake) el._sgbShake(); };

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

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

<!-- keeps shaking itself, green base: -->
<div data-snow-globe data-autoplay data-color="#166534" style="height: 320px"></div>