Memory Match Cards
A shuffled grid of face-down cards. Turn two over: if they match they stay up with a little pop and a green edge, if not they give a shake and turn back. Find every pair and the cards do a wave, then shuffle for a new game.
Tap two cards to find a pair
How to use it
- Paste the snippet just before
</head>. - Add
data-memory-match-cardsto a box holding one picture for each pair, with anaria-labelnaming it. - The second card of each pair is made for you, and they’re all shuffled into a grid.
Options
Add any of these attributes to the same element.
data-columns | Cards across. Default 4. Or set --mmc-cols in CSS. |
data-delay | How long a wrong pair stays up. Default 900 ms. |
data-duration | Time for a card to turn. Default 420 ms. |
data-hold | Time after a win before a new game. Default 2000 ms. |
data-back | A colour or CSS background for the card backs. |
data-autoplay | Milliseconds between turns: it plays itself, remembering most cards it has seen, until someone takes over. On its own it means 650. |
The cards work with a keyboard (Tab to one, Enter to turn it) and each says whether it’s face down, what it shows, or that it’s matched. After nine seconds without a touch the autoplay takes over again. The cards are 3 : 4; change --mmc-gap and --mmc-r for the gap and the corners.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Memory Match Cards: a shuffled grid of face-down cards. Turn two over;
// if they match they stay up with a little pop, if not they shake and turn
// back. Find every pair and the cards do a wave, then shuffle for a new game.
// Usage: <div data-memory-match-cards data-columns="4">
// <span aria-label="Star">★</span> <span aria-label="Moon">☾</span> …
// </div>
// Give one child per pair: the script makes the second card of each pair
// and shuffles them.
// Optional: data-columns (default 4; or set --mmc-cols in CSS), data-delay
// (ms a wrong pair stays up, default 900), data-duration (ms for a card to
// turn, default 420), data-hold (ms after a win before a new game, default
// 2000), data-back (a colour or CSS background for the backs),
// data-autoplay (ms between turns: plays by itself until someone takes
// over; on its own it means 650).
var DEFAULTS = { columns: 4, delay: 900, duration: 420, hold: 2000, autoplay: 650 };
var css =
'.mmc{display:grid;grid-template-columns:repeat(var(--mmc-cols,4),minmax(0,1fr));gap:var(--mmc-gap,10px);-webkit-tap-highlight-color:transparent}' +
'.mmc-card{position:relative;aspect-ratio:3/4;perspective:700px;cursor:pointer;outline:none;border-radius:var(--mmc-r,10px)}' +
'.mmc-card:focus-visible{box-shadow:0 0 0 3px #fff,0 0 0 5px #6a0dad}' +
'.mmc-in{position:absolute;inset:0;transform-style:preserve-3d}' +
'.mmc-in>span{position:absolute;inset:0;display:grid;place-items:center;border-radius:inherit;-webkit-backface-visibility:hidden;backface-visibility:hidden;box-sizing:border-box;border-radius:var(--mmc-r,10px)}' +
'.mmc-back{background:repeating-linear-gradient(45deg,rgba(255,255,255,.13) 0 4px,transparent 4px 9px),linear-gradient(135deg,#8b2fd6,#4c0a8f);border:3px solid #fff;box-shadow:0 2px 6px rgba(30,10,60,.22)}' +
'.mmc-back::after{content:"";position:absolute;inset:14%;border:1.5px solid rgba(255,255,255,.4);border-radius:50%}' +
'.mmc-face{transform:rotateY(180deg);background:#fff;box-shadow:0 2px 6px rgba(30,10,60,.18);transition:box-shadow .3s}' +
'.mmc-face>*{max-width:70%;max-height:70%}' +
'.mmc-done .mmc-face{box-shadow:0 0 0 2.5px #22c55e,0 4px 14px rgba(34,197,94,.35)}' +
'.mmc-card.mmc-done{cursor:default}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function shuffle(a) {
for (var i = a.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1)), t = a[i];
a[i] = a[j]; a[j] = t;
}
return a;
}
function setup(el) {
var d = el.dataset;
var faces = Array.prototype.slice.call(el.children);
if (!faces.length) return;
el.classList.add('mmc');
if (d.columns) el.style.setProperty('--mmc-cols', d.columns);
var hidden = !!el.closest('[aria-hidden="true"]');
el.textContent = '';
var cards = [];
faces.forEach(function (face, key) {
var name = face.getAttribute('aria-label') || face.textContent.trim() || 'picture ' + (key + 1);
[face, face.cloneNode(true)].forEach(function (f) {
f.removeAttribute('id');
var card = document.createElement('div');
card.className = 'mmc-card';
var inner = document.createElement('div');
inner.className = 'mmc-in';
var back = document.createElement('span');
back.className = 'mmc-back';
if (d.back) back.style.background = d.back;
var front = document.createElement('span');
front.className = 'mmc-face';
front.appendChild(f);
inner.appendChild(back);
inner.appendChild(front);
card.appendChild(inner);
var c = { el: card, inner: inner, key: key, name: name, up: false, done: false };
if (!hidden) {
card.setAttribute('role', 'button');
card.tabIndex = 0;
card.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); human(); pick(el, c); }
});
}
card.addEventListener('click', function (e) { if (e.isTrusted) human(); pick(el, c); });
cards.push(c);
});
});
var s = el._mmcS = { cards: cards, up: [], lock: false, memory: {}, timer: 0, visible: false, bot: d.autoplay !== undefined, idle: 0 };
deal(el);
if (reduced()) s.instant = true;
function human() {
if (!s.bot && !s.botOff) return;
s.bot = false; s.botOff = true;
clearTimeout(s.idle);
// Hand back to the autoplay after a while without a touch.
s.idle = setTimeout(function () { s.bot = true; s.botOff = false; s.memory = {}; tick(el); }, 9000);
}
if (d.autoplay === undefined) return;
new IntersectionObserver(function (en) {
s.visible = en[0].isIntersecting;
if (s.visible && !s.timer) tick(el);
}, { threshold: 0.2 }).observe(el);
}
// Shuffle the cards into the grid, all face down.
function deal(el) {
var s = el._mmcS;
shuffle(s.cards).forEach(function (c, i) {
c.up = c.done = false;
c.i = i;
c.el.classList.remove('mmc-done');
anime.utils.set(c.inner, { rotateY: 0 });
label(c);
el.appendChild(c.el);
});
s.up = [];
s.memory = {};
}
function label(c) {
c.el.setAttribute('aria-label', 'Card ' + (c.i + 1) + ', ' + (c.done ? c.name + ', matched' : c.up ? c.name : 'face down'));
}
function turn(el, c, up) {
var dur = +el.dataset.duration || DEFAULTS.duration;
c.up = up;
label(c);
if (el._mmcS.instant) { anime.utils.set(c.inner, { rotateY: up ? 180 : 0 }); return; }
anime.animate(c.inner, { rotateY: up ? 180 : 0, scale: [1, 1.06, 1], duration: dur, ease: 'inOut(2)' });
}
function pick(el, c) {
var s = el._mmcS, d = el.dataset;
if (s.lock || c.up || c.done) return;
turn(el, c, true);
remember(s, c);
s.up.push(c);
if (s.up.length < 2) return;
var a = s.up[0], b = s.up[1];
s.lock = true;
var dur = +d.duration || DEFAULTS.duration;
if (a.key === b.key) {
setTimeout(function () {
a.done = b.done = true;
[a, b].forEach(function (x) { x.el.classList.add('mmc-done'); label(x); delete s.memory[x.key]; });
if (!s.instant) anime.animate([a.el, b.el], { scale: [1, 1.12, 1], duration: 500, ease: 'out(3)' });
s.up = [];
s.lock = false;
if (s.cards.every(function (x) { return x.done; })) win(el);
}, dur);
} else {
setTimeout(function () {
if (!s.instant) anime.animate([a.el, b.el], { x: [0, -5, 5, -3, 3, 0], duration: 360, ease: 'inOut(2)' });
setTimeout(function () {
turn(el, a, false);
turn(el, b, false);
s.up = [];
setTimeout(function () { s.lock = false; }, s.instant ? 0 : dur * 0.6);
}, s.instant ? 0 : 380);
}, +d.delay || DEFAULTS.delay);
}
}
function remember(s, c) {
// The autoplay's memory is good but not perfect.
if (Math.random() < 0.15) return;
var m = s.memory[c.key] = s.memory[c.key] || [];
if (m.indexOf(c) < 0) m.push(c);
}
function win(el) {
var s = el._mmcS, d = el.dataset;
s.lock = true;
var hold = d.hold !== undefined ? +d.hold : DEFAULTS.hold;
if (!s.instant) {
anime.animate(s.cards.map(function (c) { return c.el; }), {
y: [0, -10, 0], scale: [1, 1.08, 1], duration: 600,
delay: anime.stagger(55, { grid: gridSize(el), from: 'center' }), ease: 'out(2)'
});
}
setTimeout(function () {
s.cards.forEach(function (c) { turn(el, c, false); });
setTimeout(function () {
if (s.instant) { deal(el); s.lock = false; return; }
var els = s.cards.map(function (c) { return c.el; });
anime.animate(els, { scale: 0.8, opacity: 0, duration: 260, ease: 'in(2)', onComplete: function () {
deal(el);
anime.animate(els, { scale: [0.8, 1], opacity: [0, 1], duration: 380, delay: anime.stagger(30), ease: 'outBack(1.6)' });
s.lock = false;
} });
}, (+d.duration || DEFAULTS.duration) + 100);
}, hold);
}
function gridSize(el) {
var cols = parseInt(getComputedStyle(el).getPropertyValue('--mmc-cols'), 10) || DEFAULTS.columns;
return [cols, Math.ceil(el._mmcS.cards.length / cols)];
}
// The autoplay: turn over a known pair if it remembers one, otherwise try
// a card it hasn't seen.
function tick(el) {
var s = el._mmcS, d = el.dataset;
clearTimeout(s.timer);
s.timer = 0;
if (!s.visible || !s.bot) return;
var every = +d.autoplay || DEFAULTS.autoplay;
s.timer = setTimeout(function () { tick(el); }, every);
if (s.lock || document.hidden) return;
var open = s.cards.filter(function (c) { return !c.done && !c.up; });
if (!open.length) return;
var seen = function (c) { return (s.memory[c.key] || []).indexOf(c) >= 0; };
var next = null;
if (s.up.length === 1) {
var k = s.up[0].key;
next = (s.memory[k] || []).filter(function (c) { return c !== s.up[0] && !c.done; })[0];
} else {
for (var key in s.memory) {
var m = s.memory[key].filter(function (c) { return !c.done; });
if (m.length > 1) { next = m[0]; break; }
}
}
if (!next) {
var fresh = open.filter(function (c) { return !seen(c); });
var from = fresh.length ? fresh : open;
next = from[Math.floor(Math.random() * from.length)];
}
pick(el, next);
}
function init() {
document.querySelectorAll('[data-memory-match-cards]').forEach(function (el) {
if (el._mmc) return;
el._mmc = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-memory-match-cards data-columns="4">
<img src="star.svg" alt="" aria-label="Star">
<img src="heart.svg" alt="" aria-label="Heart">
<img src="moon.svg" alt="" aria-label="Moon">
<img src="leaf.svg" alt="" aria-label="Leaf">
</div>