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
- Paste the snippet just before
</head>. - 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-loop | Shut all the doors after a while and open them again. |
data-count | How many doors, 1 to 24. Default 12. |
data-stagger | Time between one door and the next. Default 700 ms. |
data-colors | Comma-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.
<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><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>