You Might Also Like
Memory Match Game — Free HTML CSS JS Snippet
Memory Match Game · Cards · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Memory Match Game — 3D Card Flip, Match-Lock State Machine & Lightweight Confetti Burst in Vanilla JS

A memory-matching game looks simple — flip two cards, check if they match, repeat — but a naive click handler breaks almost immediately under fast, real-world clicking. This snippet implements the classic game using the same 3D CSS flip mechanics as the site's 3D flip card and flip card modal snippets, paired with a small but essential game-state guard, a move counter, a live elapsed timer, and a hand-rolled particle-burst celebration effect using the Web Animations API rather than a canvas confetti library.
The 3D flip: perspective, preserve-3d, backface-visibility
Each card lives inside a .card-scene wrapper; the grid container itself carries perspective: 900px, establishing the 3D viewing context all cards share. Each .card has transform-style: preserve-3d so its two face children are rendered in that same 3D space rather than flattened. The .face-front is pre-rotated to rotateY(180deg) at rest and both faces use backface-visibility: hidden, so only one face is ever visible at a given rotation — exactly the same four-property combination documented in this library's other 3D flip snippets. Toggling .flipped on the card rotates it 180 degrees on the Y axis, simultaneously turning the back (question-mark) face away from the viewer and bringing the pre-rotated front face around to face them.
Why a lock flag is required, not optional
Without a guard, the click handler has an obvious race condition: a player flips two mismatched cards, sees they do not match, and — before the short "let me memorize this" pause finishes and the mismatched pair auto-flips back — clicks a third card. At that moment flippedCards already holds two entries; a naive implementation would push a third, and either the match-comparison logic silently breaks (comparing the wrong pair, or leaving a card permanently flipped) or the player briefly sees a card face they should not have access to yet, undermining the entire point of the memory challenge. This snippet's onCardClick() defends against exactly that: the moment a second card is flipped, locked is set to true and moves is incremented immediately, before any setTimeout fires. Every subsequent click, no matter how fast, hits the if (locked) return; guard at the very top of onCardClick() and is silently ignored until resolveMatch() or resolveMismatch() finishes and explicitly sets locked = false again. This makes "exactly zero or two cards are ever flipped-and-unresolved at once" a true invariant instead of a hopeful assumption.
Two different pauses for two different outcomes
A match is confirmed after a short 320ms pause (long enough to register visually, short enough not to feel like a stall) before resolveMatch() runs. A mismatch uses a longer 850ms pause before resolveMismatch() flips both cards back — deliberately longer, because the entire point of that pause is giving the player time to actually memorize which icon was where before the cards hide it again. Both pauses funnel through the same locked flag, so the length of the delay never affects correctness, only pacing.
The match pulse-glow and lightweight confetti
A successful match adds .matched, which triggers a pulse-glow keyframe animation (a brief scale-up-and-back on the already-flipped card) and a persistent green glow via box-shadow on the front face. Independently, burstConfetti() reads the card's live getBoundingClientRect() to find its screen-space center, then spawns ten small colored divs positioned at that point and animates each one with the native Element.animate() Web Animations API — no requestAnimationFrame loop, no external confetti library, no canvas. Each bit gets a random angle and distance and animates from its spawn point to that offset with fading opacity and a random rotation, and removes itself from the DOM in the animation's onfinish callback so nothing lingers. Finishing the last pair triggers a larger 40-piece burst from the grid's center as a bigger celebration moment, layered under the win banner that fades in with the final move count and elapsed time.
Move counter and live timer
moves increments once per pair-attempt (not per single card click), matching how memory games conventionally score. The timer starts lazily — startTimerIfNeeded() only fires Date.now() and starts a setInterval on the very first card flip of a game, not on page load — so idle time before a player's first move is never counted, and it is stopped precisely once the final pair matches by reading the same startTime reference used to display the running clock, guaranteeing the final recorded time matches what was on-screen the moment the game ended.
Build with AI
Build, Understand, Optimize, and Extend It With AI
Paste this snippet's JS into an AI assistant like Claude and ask it to walk through the exact sequence of events that would go wrong if the locked flag were removed — specifically what a fast third click does to the flippedCards array and why that breaks match detection. It is also worth asking whether resolveMismatch and resolveMatch could be merged into one function with a single "isMatch" branch to reduce duplication, or whether keeping them separate is clearer given their different pause durations. For extending the game, ask for a difficulty selector that changes the grid size and icon count, a best-time leaderboard using localStorage, or a two-player alternating-turns mode where a mismatch passes the turn to the other player.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build a memory-matching card game in plain HTML, CSS, and JavaScript using a real 3D CSS card flip — no canvas, no animation library.
Requirements:
- A grid of face-down cards, each built from a perspective-contained wrapper and a card element using transform-style: preserve-3d, with a back face (question mark) and a front face (icon) both using backface-visibility: hidden, the front face pre-rotated to rotateY(180deg) so toggling a single "flipped" class on the card performs the actual flip.
- Clicking a face-down, non-matched card flips it and adds it to a small "currently flipped" list; clicking a second card triggers a comparison after a short delay.
- A "lock" boolean flag that gets set to true the instant a second card is flipped (before any comparison delay runs) and is only cleared after the match or mismatch has fully resolved, with the click handler ignoring all clicks while locked is true. Explain in a comment the exact race condition this prevents: a fast third click landing while two mismatched cards are still shown, before their automatic flip-back has happened.
- Use a noticeably longer pause before flipping mismatched cards back down than before confirming a match, since the mismatch pause is what gives the player time to memorize the two revealed cards.
- On a successful match, play a pulse/glow animation on both matched cards and spawn a small lightweight confetti-style particle burst (a handful of small colored elements animating outward and fading, built with the native Element.animate() Web Animations API rather than a canvas or external library) from the matched cards' position.
- Track and display a move counter (incremented once per pair attempt) and an elapsed timer that starts on the first card flip (not on page load) and stops the moment all pairs are matched.
- When every pair is matched, show a win banner with the final move count and time, plus a larger confetti burst from the center of the board.Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.
Step by step
How to Use
- 1Click any face-down card to flip itThe card rotates 180 degrees around the Y axis using a real 3D transform, revealing its icon. The move timer starts counting from this first click.
- 2Click a second card to attempt a matchOnce two cards are flipped, further clicks are briefly ignored while the game checks the pair — this prevents a fast third click from interrupting the comparison.
- 3Watch a matched pair pulse and glowMatching icons trigger a quick scale-up pulse plus a green glow on both cards, and a small burst of colored particles fires from each card's position.
- 4Watch a mismatched pair flip backNon-matching cards stay visible slightly longer than a match (to give you time to memorize them) before automatically flipping face-down again.
- 5Track your move count and elapsed time in the HUDBoth counters update live above the grid — moves increases once per pair attempt, and the timer keeps running until the very last pair is matched.
- 6Clear the board to trigger the win celebrationMatching all pairs stops the timer, fires a larger confetti burst from the center of the grid, and shows a banner with your final move count and time.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
Checking flippedCards.length === 2 alone is not enough, because that check happens synchronously on click while the mismatch-resolution setTimeout is still pending in the background. A player who clicks fast enough could flip a third card during the window after two mismatched cards are shown but before their flip-back timeout has fired, at which point flippedCards briefly holds an invalid third entry. Setting locked = true the instant the second card of a pair is flipped, checked at the very top of every click handler call, closes that window completely regardless of timing.
A match pause (320ms) only needs to be long enough to visually confirm the pair before the pulse-glow plays — the cards are staying flipped anyway. A mismatch pause (850ms) has to give the player enough real time to actually study and memorize both revealed icons before they flip back and hide again, since remembering card positions is the entire point of the game; a too-short mismatch pause makes the game frustrating rather than challenging.
Edit the ICONS array at the top of the script — each entry becomes one matched pair, so an 8-item array produces a 16-card (4x8 grid works well up to 6 pairs; adjust grid-template-columns in the CSS for larger sets) grid. buildDeck() automatically duplicates and shuffles whatever list you provide, so no other code needs to change.
Yes. In React, replace the module-level deck/flippedCards/locked variables with useState, and move startTimerIfNeeded's setInterval into a useEffect, storing the interval id in a ref and calling clearInterval on it in the cleanup function so a mid-game unmount does not leave a stray timer running. In Vue, hold the same state in ref()/reactive() and start/stop the interval in onMounted/onUnmounted. In Angular, manage it as component fields and clear the interval in ngOnDestroy. The Web Animations API confetti burst (Element.animate()) needs no cleanup in any framework since each animation removes its own DOM node in its onfinish callback.
The Web Animations API lets the browser's compositor drive each particle's transform and opacity animation independently and efficiently, without a JavaScript frame loop tracking dozens of particles' positions manually. Each confetti-bit is a tiny absolutely-positioned div whose entire animation (translate, rotate, fade) is described once as keyframes and handed to the browser; the onfinish callback removing the element from the DOM is the only JS involvement after spawn, keeping the effect cheap even when many bursts fire in quick succession.