← Seasonal

Advent Calendar Doors

A grid of little numbered doors, jumbled like a real advent calendar. They swing open on their hinges in number order, one by one, and a surprise pops out from behind each: a star, a present, a bell, a candy cane. Tap any door to open or close it yourself.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty <div data-advent-calendar-doors> where the calendar should go. The doors start opening when it scrolls into view.

Options

Add any of these attributes to the same element.

data-loopShut all the doors after a while and open them again.
data-countHow many doors, 1 to 24. Default 12.
data-staggerTime between one door and the next. Default 700 ms.
data-colorsComma-separated door colours. Default red, green and blue.
data-autoplay="false"Don’t open any doors by themselves; leave it to the visitor.

It is 8:5 with a transparent background by default; set your own height or background on the element. The grid picks its rows and columns to fit. Each door is a real button; after a visitor opens one, the doors wait a few seconds before carrying on by themselves. With reduced motion every door is shown open.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Advent Calendar Doors: a grid of little numbered doors, jumbled like a
    // real advent calendar. They swing open on their hinges in number order,
    // one by one, and a surprise pops out from behind each: a star, a
    // present, a bell, a candy cane… Click or tap any door to open or close
    // it yourself. Loop it and they all shut again and start over.
    // Usage: <div data-advent-calendar-doors></div>
    // It is 8:5 by default with a transparent background.
    // Optional: data-loop (close them all and open them again), data-count
    // (how many doors, 1 to 24, default 12), data-stagger (ms between doors,
    // default 700), data-colors (comma-separated door colours, default red,
    // green and blue), data-autoplay="false" (only open when clicked).
    var DEFAULTS = { count: 12, stagger: 700, colors: ['#b91c1c', '#166534', '#1e3a8a'] };

    var css =
      '[data-advent-calendar-doors]{display:block;position:relative;width:100%;aspect-ratio:8/5}' +
      '.acd-board{position:absolute;inset:0;display:grid;box-sizing:border-box}' +
      '.acd-cell{position:relative;display:block;margin:0;padding:0;border:0;border-radius:6px;background:none;font:inherit;color:inherit;perspective:900px;-webkit-tap-highlight-color:transparent}' +
      'button.acd-cell{cursor:pointer}' +
      'button.acd-cell:focus-visible{outline:2px solid #facc15;outline-offset:3px}' +
      '.acd-in{position:absolute;inset:0;border-radius:inherit;overflow:hidden;display:grid;place-items:center;background:radial-gradient(circle at 50% 42%,#fffbeb,#fde68a 62%,#d97706);box-shadow:inset 0 2px 7px rgba(0,0,0,.45)}' +
      '.acd-icon{width:64%;height:64%;opacity:0;display:block}' +
      '.acd-door{position:absolute;inset:0;border-radius:inherit;transform-origin:0 50%;transform-style:preserve-3d}' +
      '.acd-face{position:absolute;inset:0;border-radius:inherit;-webkit-backface-visibility:hidden;backface-visibility:hidden;display:grid;place-items:center}' +
      '.acd-front{background:linear-gradient(145deg,rgba(255,255,255,.2),rgba(255,255,255,0) 45%,rgba(0,0,0,.18)),var(--acd-c);color:#fff4d6;font:800 var(--acd-fs,20px)/1 Georgia,"Times New Roman",serif;text-shadow:0 1px 0 rgba(0,0,0,.35);box-shadow:0 1px 3px rgba(0,0,0,.35),inset 0 0 0 1px rgba(255,255,255,.12)}' +
      '.acd-front::before{content:"";position:absolute;inset:8%;border:1px dashed rgba(255,244,214,.4);border-radius:3px}' +
      '.acd-front::after{content:"";position:absolute;right:9%;top:50%;width:var(--acd-knob,5px);height:var(--acd-knob,5px);margin-top:calc(var(--acd-knob,5px) / -2);border-radius:50%;background:#facc15;box-shadow:0 1px 1px rgba(0,0,0,.4)}' +
      '.acd-back{transform:rotateY(180deg);background:linear-gradient(90deg,rgba(0,0,0,.45),rgba(0,0,0,.25)),var(--acd-c)}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    // Little surprises, each drawn in a 24 x 24 box.
    var ICONS = [
      '<path d="M12 2.5l2.8 6 6.5.7-4.9 4.4 1.4 6.4L12 16.8 6.2 20l1.4-6.4L2.7 9.2l6.5-.7z" fill="#f59e0b" stroke="#b45309" stroke-width=".8" stroke-linejoin="round"/>',
      '<rect x="4" y="10" width="16" height="11" rx="1.2" fill="#dc2626"/><rect x="3" y="7" width="18" height="4" rx="1" fill="#ef4444"/><rect x="10.5" y="7" width="3" height="14" fill="#facc15"/><path d="M12 7C9 3 5.5 4.5 7.5 7M12 7c3-4 6.5-2.5 4.5 0" fill="none" stroke="#facc15" stroke-width="1.6" stroke-linecap="round"/>',
      '<path d="M12 2L6 10h3l-4 5h3.5L4 20h16l-4.5-5H19l-4-5h3z" fill="#16a34a" stroke="#14532d" stroke-width=".6" stroke-linejoin="round"/><rect x="10.5" y="20" width="3" height="3" fill="#92400e"/><circle cx="10" cy="12.5" r="1" fill="#facc15"/><circle cx="14" cy="16.5" r="1" fill="#ef4444"/><circle cx="9" cy="17.5" r="1" fill="#38bdf8"/>',
      '<path d="M12 3a1.5 1.5 0 0 1 1.5 1.5c3 .8 4.5 3.5 4.5 7v3.5l2 2.5H4l2-2.5V11.5c0-3.5 1.5-6.2 4.5-7A1.5 1.5 0 0 1 12 3z" fill="#eab308" stroke="#a16207" stroke-width=".7"/><circle cx="12" cy="19.5" r="2" fill="#a16207"/><path d="M8.5 9.5c.4-1.3 1.2-2.2 2.3-2.6" stroke="#fef9c3" stroke-width="1.1" fill="none" stroke-linecap="round"/>',
      '<path d="M9 21V8a3.5 3.5 0 0 1 7 0v1.5" fill="none" stroke="#fff" stroke-width="3.6" stroke-linecap="round"/><path d="M9 21V8a3.5 3.5 0 0 1 7 0v1.5" fill="none" stroke="#dc2626" stroke-width="3.6" stroke-linecap="butt" stroke-dasharray="2.2 2.2"/>',
      '<g stroke="#0ea5e9" stroke-width="1.6" stroke-linecap="round"><path d="M12 2.5v19M3.8 7.2l16.4 9.6M3.8 16.8l16.4-9.6"/><path d="M9.5 3.8L12 6l2.5-2.2M9.5 20.2L12 18l2.5 2.2M4 10.8l3.2-1.3L6.4 6.3M20 13.2l-3.2 1.3.8 3.2M4 13.2l3.2 1.3-.8 3.2M20 10.8l-3.2-1.3.8-3.2" fill="none"/></g>',
      '<path d="M12 20.5S3 15 3 9a4.5 4.5 0 0 1 9-1 4.5 4.5 0 0 1 9 1c0 6-9 11.5-9 11.5z" fill="#e11d48"/><path d="M6.5 7.5c.8-.9 1.8-1.2 2.8-1" stroke="#fda4af" stroke-width="1.2" fill="none" stroke-linecap="round"/>',
      '<rect x="10" y="3" width="4" height="3" rx=".6" fill="#ca8a04"/><path d="M12 3c-1.2-1.6 1.2-1.6 0 0" fill="none" stroke="#ca8a04"/><circle cx="12" cy="14" r="8" fill="#7c3aed"/><path d="M4.5 12.5c2.5 1.4 12.5 1.4 15 0M5 16.5c2.5 1.4 11.5 1.4 14 0" stroke="#facc15" stroke-width="1.1" fill="none"/><circle cx="9" cy="10.5" r="1.6" fill="#fff" opacity=".6"/>',
      '<path d="M15.5 3.5A8.5 8.5 0 1 0 20.5 16 7 7 0 0 1 15.5 3.5z" fill="#facc15" stroke="#ca8a04" stroke-width=".7"/><circle cx="18.5" cy="6" r=".9" fill="#ca8a04"/><circle cx="20" cy="10" r=".6" fill="#ca8a04"/>',
      '<path d="M8 3h8v9l3.5 3.5a3 3 0 0 1-4.2 4.3L9.5 14A4 4 0 0 1 8 11z" fill="#dc2626"/><rect x="7.5" y="2.5" width="9" height="4" rx="1.5" fill="#f8fafc"/><circle cx="12" cy="11" r=".9" fill="#facc15"/><circle cx="14" cy="15" r=".9" fill="#facc15"/>',
      '<circle cx="12" cy="12" r="8.5" fill="#d6a46b" stroke="#a16207" stroke-width=".8"/><circle cx="9" cy="9" r="1.2" fill="#422006"/><circle cx="14.5" cy="10" r="1.2" fill="#422006"/><circle cx="10.5" cy="15" r="1.2" fill="#422006"/><circle cx="15" cy="14.5" r=".9" fill="#422006"/>',
      '<circle cx="12" cy="16" r="5.5" fill="#f8fafc" stroke="#94a3b8" stroke-width=".7"/><circle cx="12" cy="7.5" r="4" fill="#f8fafc" stroke="#94a3b8" stroke-width=".7"/><circle cx="10.6" cy="7" r=".7" fill="#0f172a"/><circle cx="13.4" cy="7" r=".7" fill="#0f172a"/><path d="M12 8.2l2.5.8-2.5.5z" fill="#f97316"/><path d="M8.5 11h7" stroke="#dc2626" stroke-width="1.6" stroke-linecap="round"/>'
    ];

    // A small seeded random, so the jumbled numbers stay put.
    function seeded(seed) { return function () { seed = (seed * 16807) % 2147483647; return (seed - 1) / 2147483646; }; }

    function build(el) {
      var d = el.dataset;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var count = Math.max(1, Math.min(24, d.count !== undefined ? Math.round(+d.count) : DEFAULTS.count));
      var stagger = Math.max(100, d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger);
      var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : DEFAULTS.colors;
      var autoplay = d.autoplay !== 'false';
      var passive = !!el.closest('a,[aria-hidden="true"]');
      var tag = passive ? 'span' : 'button';

      var nums = [];
      for (var i = 1; i <= count; i++) nums.push(i);
      var rand = seeded(count * 7919 + 13);
      for (i = nums.length - 1; i > 0; i--) { var j = Math.floor(rand() * (i + 1)); var tmp = nums[i]; nums[i] = nums[j]; nums[j] = tmp; }

      var board = document.createElement('div');
      board.className = 'acd-board';
      var html = '';
      nums.forEach(function (n, k) {
        var c = colors[k % colors.length];
        html += '<' + tag + (passive ? '' : ' type="button" aria-pressed="false" aria-label="Door ' + n + '"') + ' class="acd-cell" data-n="' + n + '" style="--acd-c:' + c + '">' +
          '<span class="acd-in" aria-hidden="true"><svg class="acd-icon" viewBox="0 0 24 24" focusable="false">' + ICONS[(n - 1) % ICONS.length] + '</svg></span>' +
          '<span class="acd-door" aria-hidden="true"><span class="acd-face acd-front">' + n + '</span><span class="acd-face acd-back"></span></span>' +
          '</' + tag + '>';
      });
      board.innerHTML = html;
      if (passive) board.setAttribute('aria-hidden', 'true');
      el.appendChild(board);

      var D = [];
      board.querySelectorAll('.acd-cell').forEach(function (c) {
        D[+c.getAttribute('data-n') - 1] = { cell: c, door: c.querySelector('.acd-door'), icon: c.querySelector('.acd-icon'), open: false, a: null };
      });

      function layout() {
        var w = el.clientWidth, h = el.clientHeight;
        if (!w || !h) return;
        var cols = Math.max(1, Math.min(count, Math.round(Math.sqrt(count * w / h))));
        var rows = Math.ceil(count / cols);
        var pad = Math.max(8, Math.min(24, Math.min(w, h) * 0.06));
        var gap = Math.max(4, Math.min(12, Math.min(w, h) * 0.025));
        board.style.padding = pad + 'px';
        board.style.gap = gap + 'px';
        board.style.gridTemplateColumns = 'repeat(' + cols + ',1fr)';
        board.style.gridTemplateRows = 'repeat(' + rows + ',1fr)';
        var cw = (w - pad * 2 - gap * (cols - 1)) / cols, ch = (h - pad * 2 - gap * (rows - 1)) / rows;
        var m = Math.min(cw, ch);
        el.style.setProperty('--acd-fs', Math.max(10, m * 0.42).toFixed(1) + 'px');
        el.style.setProperty('--acd-knob', Math.max(3, m * 0.07).toFixed(1) + 'px');
      }
      layout();
      if (window.ResizeObserver) new ResizeObserver(layout).observe(el);

      function setOpen(o, open, instant) {
        o.open = open;
        if (o.a) o.a.pause();
        o.cell.style.zIndex = open ? 2 : '';
        if (!passive) o.cell.setAttribute('aria-pressed', open ? 'true' : 'false');
        if (instant) {
          o.door.style.transform = open ? 'rotateY(-102deg)' : 'rotateY(0deg)';
          o.icon.style.opacity = open ? 1 : 0;
          o.icon.style.transform = 'none';
          return;
        }
        if (open) {
          o.a = anime.animate(o.door, { rotateY: -102, duration: 900, ease: 'outBack(1.3)' });
          anime.animate(o.icon, { opacity: [0, 1], scale: [0.2, 1], rotate: [-25, 0], duration: 650, delay: 160, ease: 'outBack(2.5)' });
        } else {
          o.a = anime.animate(o.door, {
            rotateY: 0, duration: 450, ease: 'in(2)',
            onComplete: function () { o.cell.style.zIndex = ''; }
          });
          anime.animate(o.icon, { opacity: 0, scale: 0.6, duration: 250, ease: 'in(2)' });
        }
      }

      if (reduced()) { D.forEach(function (o) { setOpen(o, true, true); }); return; }

      var tl = null, visible = false, idle = null, userPaused = false;
      function stop() { if (tl) { tl.pause(); tl = null; } }
      function run() {
        stop();
        if (!autoplay || userPaused || !visible) return;
        var closed = D.filter(function (o) { return !o.open; });
        tl = anime.createTimeline({
          onComplete: function () {
            tl = null;
            if (loop && visible && !userPaused) run();
          }
        });
        var at = 300;
        closed.forEach(function (o) {
          tl.call(function () { setOpen(o, true); }, at);
          at += stagger;
        });
        if (loop) {
          at += 2600;
          D.slice().reverse().forEach(function (o, k) {
            tl.call(function () { setOpen(o, false); }, at + k * 70);
          });
          tl.add({ duration: 1 }, at + D.length * 70 + 900);
        }
      }

      if (!passive) board.addEventListener('click', function (e) {
        var c = e.target.closest('.acd-cell');
        if (!c) return;
        var o = D[+c.getAttribute('data-n') - 1];
        setOpen(o, !o.open);
        // A visitor is exploring: hold the autoplay, pick it up again after a while.
        userPaused = true;
        stop();
        clearTimeout(idle);
        idle = setTimeout(function () { userPaused = false; run(); }, 9000);
      });

      var started = false;
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (visible && !started) { started = true; run(); }
        else if (visible && tl) tl.play();
        else if (visible && loop) run();
        else if (!visible && tl) tl.pause();
      }, { threshold: 0.25 }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-advent-calendar-doors]').forEach(function (el) {
        if (el._acd) return;   // idempotent
        el._acd = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-advent-calendar-doors style="background: #14532d"></div>

<!-- all 24 doors, gold and red: -->
<div data-advent-calendar-doors data-count="24" data-colors="#b45309,#991b1b" style="height: 420px"></div>