← Cards & images

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

  1. Paste the snippet just before </head>.
  2. Add data-memory-match-cards to a box holding one picture for each pair, with an aria-label naming it.
  3. 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-columnsCards across. Default 4. Or set --mmc-cols in CSS.
data-delayHow long a wrong pair stays up. Default 900 ms.
data-durationTime for a card to turn. Default 420 ms.
data-holdTime after a win before a new game. Default 2000 ms.
data-backA colour or CSS background for the card backs.
data-autoplayMilliseconds 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.

1 · Snippet
<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>
2 · Your HTML
<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>