UI Components Medium
Memory Card Game
A matching card game with smooth 3D flip animations, move counting, and a win-state celebration.
Open in Lab
MCP
vanilla-js css react tailwind vue svelte
Targets: TS JS HTML React Vue Svelte
Code
:root {
--game-bg: #0f172a;
--game-surface: rgba(255, 255, 255, 0.04);
--game-border: rgba(255, 255, 255, 0.08);
--game-primary: #8b5cf6;
--game-primary-glow: rgba(139, 92, 246, 0.3);
--game-secondary: #f43f5e;
--card-back: #1e293b;
--card-back-border: rgba(139, 92, 246, 0.2);
--card-front: #141d2e;
--card-matched: rgba(139, 92, 246, 0.15);
--text-primary: #f8fafc;
--text-muted: #94a3b8;
}
.memory-game-container {
max-width: 520px;
margin: 0 auto;
font-family: "Inter", system-ui, sans-serif;
position: relative;
padding: 1.5rem;
background: var(--game-surface);
border: 1px solid var(--game-border);
border-radius: 24px;
backdrop-filter: blur(16px);
}
.game-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
background: rgba(255, 255, 255, 0.04);
padding: 0.875rem 1.25rem;
border-radius: 14px;
border: 1px solid var(--game-border);
}
.meta-item {
font-weight: 600;
font-size: 0.875rem;
color: var(--text-muted);
letter-spacing: 0.025em;
}
.meta-item span {
color: var(--text-primary);
font-weight: 700;
}
.reset-btn,
.play-again-btn {
background: linear-gradient(135deg, var(--game-primary), #a78bfa);
color: white;
border: none;
padding: 0.5rem 1.125rem;
border-radius: 10px;
font-weight: 600;
font-size: 0.813rem;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 0 16px var(--game-primary-glow);
}
.reset-btn:hover,
.play-again-btn:hover {
opacity: 0.9;
transform: translateY(-1px);
box-shadow: 0 4px 24px var(--game-primary-glow);
}
.card-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.75rem;
perspective: 1000px;
}
.memory-card {
aspect-ratio: 1;
position: relative;
cursor: pointer;
transform-style: preserve-3d;
transition: transform 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.memory-card.flipped {
transform: rotateY(180deg);
}
.memory-card.matched {
transform: rotateY(180deg);
pointer-events: none;
}
.card-face {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.75rem;
transition: box-shadow 0.3s;
}
.card-back {
background: var(--card-back);
border: 1px solid var(--card-back-border);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.card-back::after {
content: "?";
font-size: 1.25rem;
font-weight: 700;
color: rgba(139, 92, 246, 0.4);
font-family: "Inter", sans-serif;
}
.card-back:hover {
border-color: rgba(139, 92, 246, 0.5);
box-shadow: 0 0 20px rgba(139, 92, 246, 0.15);
}
.card-front {
background: var(--card-front);
border: 2px solid var(--game-primary);
transform: rotateY(180deg);
box-shadow: 0 0 20px var(--game-primary-glow);
}
.memory-card.matched .card-front {
background: var(--card-matched);
border-color: #a78bfa;
box-shadow: 0 0 24px var(--game-primary-glow);
}
.game-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(15, 23, 42, 0.85);
backdrop-filter: blur(12px);
display: flex;
align-items: center;
justify-content: center;
z-index: 20;
border-radius: 24px;
}
.overlay-content {
text-align: center;
padding: 2rem;
}
.overlay-content h2 {
color: var(--text-primary);
font-size: 2rem;
font-weight: 800;
margin-bottom: 0.5rem;
}
.overlay-content p {
color: var(--text-muted);
font-size: 1rem;
margin-bottom: 1.5rem;
}
@media (max-width: 480px) {
.card-grid {
gap: 0.5rem;
}
.card-face {
font-size: 1.25rem;
}
}const emojis = ["🚀", "🎨", "🎮", "💡", "🎵", "⚡", "🔥", "🌈"];
const cards = [...emojis, ...emojis];
let flippedCards = [];
let matchedCount = 0;
let moves = 0;
let isAnimating = false;
const grid = document.getElementById("card-grid");
const moveCountEl = document.getElementById("move-count");
const matchCountEl = document.getElementById("match-count");
const winOverlay = document.getElementById("win-overlay");
const finalMovesEl = document.getElementById("final-moves");
function shuffle(array) {
const arr = [...array];
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
function initGame() {
const shuffled = shuffle(cards);
grid.innerHTML = "";
flippedCards = [];
matchedCount = 0;
moves = 0;
moveCountEl.textContent = "0";
matchCountEl.textContent = `0/${emojis.length}`;
winOverlay.style.display = "none";
isAnimating = false;
shuffled.forEach((emoji, index) => {
const card = document.createElement("div");
card.className = "memory-card";
card.dataset.id = index;
card.dataset.value = emoji;
card.innerHTML = `
<div class="card-face card-back"></div>
<div class="card-face card-front">${emoji}</div>
`;
card.addEventListener("click", () => flipCard(card));
grid.appendChild(card);
});
}
function flipCard(card) {
if (
isAnimating ||
card.classList.contains("flipped") ||
card.classList.contains("matched") ||
flippedCards.length >= 2
)
return;
card.classList.add("flipped");
flippedCards.push(card);
if (flippedCards.length === 2) {
moves++;
moveCountEl.textContent = moves;
checkMatch();
}
}
function checkMatch() {
isAnimating = true;
const [card1, card2] = flippedCards;
const isMatch = card1.dataset.value === card2.dataset.value;
if (isMatch) {
matchedCount++;
matchCountEl.textContent = `${matchedCount}/${emojis.length}`;
// Add matched class for styling
setTimeout(() => {
card1.classList.add("matched");
card2.classList.add("matched");
}, 300);
flippedCards = [];
isAnimating = false;
if (matchedCount === emojis.length) {
showWin();
}
} else {
setTimeout(() => {
card1.classList.remove("flipped");
card2.classList.remove("flipped");
flippedCards = [];
isAnimating = false;
}, 1000);
}
}
function showWin() {
setTimeout(() => {
winOverlay.style.display = "flex";
if (finalMovesEl) finalMovesEl.textContent = moves;
}, 500);
}
document.getElementById("reset-game").addEventListener("click", initGame);
document.getElementById("play-again-btn").addEventListener("click", initGame);
initGame();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Memory Card Game</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="memory-game-container">
<div class="game-meta">
<div class="meta-item">Moves: <span id="move-count">0</span></div>
<div class="meta-item">Matches: <span id="match-count">0/8</span></div>
<button id="reset-game" class="reset-btn">Reset</button>
</div>
<div id="card-grid" class="card-grid">
<!-- Cards generated by JS -->
</div>
<div id="win-overlay" class="game-overlay" style="display: none;">
<div class="overlay-content">
<h2>🎉 You Win!</h2>
<p>Total Moves: <span id="final-moves">0</span></p>
<button id="play-again-btn" class="play-again-btn">Play Again</button>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>import { useState, useEffect, useCallback } from "react";
const EMOJIS = ["🚀", "🎨", "🎮", "💡", "🎵", "⚡", "🔥", "🌈"];
function shuffle<T>(arr: T[]): T[] {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
type Card = { id: number; emoji: string; flipped: boolean; matched: boolean };
function makeCards(): Card[] {
return shuffle([...EMOJIS, ...EMOJIS]).map((emoji, i) => ({
id: i,
emoji,
flipped: false,
matched: false,
}));
}
export default function MemoryCardGameRC() {
const [cards, setCards] = useState<Card[]>(makeCards);
const [flipped, setFlipped] = useState<number[]>([]);
const [moves, setMoves] = useState(0);
const [locked, setLocked] = useState(false);
const [won, setWon] = useState(false);
const matched = cards.filter((c) => c.matched).length / 2;
const flip = useCallback(
(id: number) => {
if (locked) return;
const card = cards.find((c) => c.id === id);
if (!card || card.flipped || card.matched) return;
if (flipped.length === 2) return;
const newFlipped = [...flipped, id];
setCards((prev) => prev.map((c) => (c.id === id ? { ...c, flipped: true } : c)));
setFlipped(newFlipped);
if (newFlipped.length === 2) {
setMoves((m) => m + 1);
const [a, b] = newFlipped.map((fid) => cards.find((c) => c.id === fid)!);
if (a.emoji === b.emoji) {
setTimeout(() => {
setCards((prev) =>
prev.map((c) =>
newFlipped.includes(c.id) ? { ...c, matched: true, flipped: true } : c
)
);
setFlipped([]);
setWon(cards.filter((c) => c.matched).length + 2 === cards.length);
}, 400);
} else {
setLocked(true);
setTimeout(() => {
setCards((prev) =>
prev.map((c) => (newFlipped.includes(c.id) ? { ...c, flipped: false } : c))
);
setFlipped([]);
setLocked(false);
}, 900);
}
}
},
[cards, flipped, locked]
);
useEffect(() => {
if (cards.every((c) => c.matched)) setWon(true);
}, [cards]);
function restart() {
setCards(makeCards());
setFlipped([]);
setMoves(0);
setLocked(false);
setWon(false);
}
return (
<div className="min-h-screen bg-[#0d1117] flex items-center justify-center p-6">
<div className="w-full max-w-sm">
<div className="flex items-center justify-between mb-4">
<div className="flex gap-4 text-sm">
<span className="text-[#8b949e]">
Moves: <strong className="text-[#e6edf3]">{moves}</strong>
</span>
<span className="text-[#8b949e]">
Matches:{" "}
<strong className="text-[#7ee787]">
{matched}/{EMOJIS.length}
</strong>
</span>
</div>
<button onClick={restart} className="text-xs text-[#58a6ff] hover:underline">
New game
</button>
</div>
<div className="grid grid-cols-4 gap-2">
{cards.map((card) => (
<button
key={card.id}
onClick={() => flip(card.id)}
disabled={card.matched}
className="aspect-square rounded-xl text-2xl transition-all duration-300 select-none"
style={{
background: card.flipped || card.matched ? "#21262d" : "#161b22",
border: `1px solid ${card.matched ? "#238636" : card.flipped ? "#58a6ff" : "#30363d"}`,
transform: card.flipped || card.matched ? "rotateY(0deg)" : "rotateY(180deg)",
}}
>
{card.flipped || card.matched ? card.emoji : ""}
</button>
))}
</div>
{won && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-10">
<div className="bg-[#161b22] border border-[#30363d] rounded-2xl p-8 text-center m-6">
<p className="text-4xl mb-3">🎉</p>
<h2 className="text-[#e6edf3] font-bold text-xl mb-1">You won!</h2>
<p className="text-[#8b949e] text-sm mb-6">Completed in {moves} moves</p>
<button
onClick={restart}
className="px-6 py-2.5 bg-[#238636] border border-[#2ea043] text-white rounded-xl font-semibold text-sm hover:bg-[#2ea043] transition-colors"
>
Play Again
</button>
</div>
</div>
)}
</div>
</div>
);
}<script setup>
import { ref, computed } from "vue";
const EMOJIS = [
"\u{1F680}",
"\u{1F3A8}",
"\u{1F3AE}",
"\u{1F4A1}",
"\u{1F3B5}",
"\u26A1",
"\u{1F525}",
"\u{1F308}",
];
function shuffle(arr) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function makeCards() {
return shuffle([...EMOJIS, ...EMOJIS]).map((emoji, i) => ({
id: i,
emoji,
flipped: false,
matched: false,
}));
}
const cards = ref(makeCards());
const flippedIds = ref([]);
const moves = ref(0);
const locked = ref(false);
const won = ref(false);
const matched = computed(() => cards.value.filter((c) => c.matched).length / 2);
function flip(id) {
if (locked.value) return;
const card = cards.value.find((c) => c.id === id);
if (!card || card.flipped || card.matched) return;
if (flippedIds.value.length === 2) return;
card.flipped = true;
const newFlipped = [...flippedIds.value, id];
flippedIds.value = newFlipped;
if (newFlipped.length === 2) {
moves.value++;
const [a, b] = newFlipped.map((fid) => cards.value.find((c) => c.id === fid));
if (a.emoji === b.emoji) {
setTimeout(() => {
a.matched = true;
b.matched = true;
flippedIds.value = [];
if (cards.value.every((c) => c.matched)) won.value = true;
}, 400);
} else {
locked.value = true;
setTimeout(() => {
a.flipped = false;
b.flipped = false;
flippedIds.value = [];
locked.value = false;
}, 900);
}
}
}
function restart() {
cards.value = makeCards();
flippedIds.value = [];
moves.value = 0;
locked.value = false;
won.value = false;
}
</script>
<template>
<div style="min-height:100vh;background:#0d1117;display:flex;align-items:center;justify-content:center;padding:1.5rem;font-family:system-ui,-apple-system,sans-serif;color:#e6edf3">
<div style="width:100%;max-width:24rem">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem">
<div style="display:flex;gap:1rem;font-size:0.875rem">
<span style="color:#8b949e">Moves: <strong style="color:#e6edf3">{{ moves }}</strong></span>
<span style="color:#8b949e">Matches: <strong style="color:#7ee787">{{ matched }}/{{ EMOJIS.length }}</strong></span>
</div>
<button @click="restart" style="font-size:0.75rem;color:#58a6ff;background:none;border:none;cursor:pointer;font-family:inherit">New game</button>
</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:0.5rem">
<button
v-for="card in cards"
:key="card.id"
@click="flip(card.id)"
:disabled="card.matched"
:style="{
aspectRatio: '1',
borderRadius: '0.75rem',
fontSize: '1.5rem',
border: `1px solid ${card.matched ? '#238636' : card.flipped ? '#58a6ff' : '#30363d'}`,
background: card.flipped || card.matched ? '#21262d' : '#161b22',
transform: card.flipped || card.matched ? 'rotateY(0deg)' : 'rotateY(180deg)',
transition: 'all 0.3s',
cursor: card.matched ? 'default' : 'pointer',
userSelect: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}"
>{{ (card.flipped || card.matched) ? card.emoji : '' }}</button>
</div>
<div v-if="won" style="position:fixed;inset:0;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;z-index:10">
<div style="background:#161b22;border:1px solid #30363d;border-radius:1rem;padding:2rem;text-align:center;margin:1.5rem">
<p style="font-size:2.5rem;margin-bottom:0.75rem">\u{1F389}</p>
<h2 style="font-weight:700;font-size:1.25rem;margin-bottom:0.25rem">You won!</h2>
<p style="color:#8b949e;font-size:0.875rem;margin-bottom:1.5rem">Completed in {{ moves }} moves</p>
<button
@click="restart"
style="padding:0.625rem 1.5rem;background:#238636;border:1px solid #2ea043;color:#fff;border-radius:0.75rem;font-weight:600;font-size:0.875rem;cursor:pointer;font-family:inherit"
>Play Again</button>
</div>
</div>
</div>
</div>
</template><script>
const EMOJIS = [
"\u{1F680}",
"\u{1F3A8}",
"\u{1F3AE}",
"\u{1F4A1}",
"\u{1F3B5}",
"\u26A1",
"\u{1F525}",
"\u{1F308}",
];
function shuffle(arr) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function makeCards() {
return shuffle([...EMOJIS, ...EMOJIS]).map((emoji, i) => ({
id: i,
emoji,
flipped: false,
matched: false,
}));
}
let cards = makeCards();
let flippedIds = [];
let moves = 0;
let locked = false;
let won = false;
$: matchedCount = cards.filter((c) => c.matched).length / 2;
function flip(id) {
if (locked) return;
const card = cards.find((c) => c.id === id);
if (!card || card.flipped || card.matched) return;
if (flippedIds.length === 2) return;
card.flipped = true;
cards = cards;
const newFlipped = [...flippedIds, id];
flippedIds = newFlipped;
if (newFlipped.length === 2) {
moves++;
const [a, b] = newFlipped.map((fid) => cards.find((c) => c.id === fid));
if (a.emoji === b.emoji) {
setTimeout(() => {
a.matched = true;
b.matched = true;
cards = cards;
flippedIds = [];
if (cards.every((c) => c.matched)) won = true;
}, 400);
} else {
locked = true;
setTimeout(() => {
a.flipped = false;
b.flipped = false;
cards = cards;
flippedIds = [];
locked = false;
}, 900);
}
}
}
function restart() {
cards = makeCards();
flippedIds = [];
moves = 0;
locked = false;
won = false;
}
</script>
<div style="min-height:100vh;background:#0d1117;display:flex;align-items:center;justify-content:center;padding:1.5rem;font-family:system-ui,-apple-system,sans-serif;color:#e6edf3">
<div style="width:100%;max-width:24rem">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem">
<div style="display:flex;gap:1rem;font-size:0.875rem">
<span style="color:#8b949e">Moves: <strong style="color:#e6edf3">{moves}</strong></span>
<span style="color:#8b949e">Matches: <strong style="color:#7ee787">{matchedCount}/{EMOJIS.length}</strong></span>
</div>
<button on:click={restart} style="font-size:0.75rem;color:#58a6ff;background:none;border:none;cursor:pointer;font-family:inherit">New game</button>
</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:0.5rem">
{#each cards as card (card.id)}
<button
on:click={() => flip(card.id)}
disabled={card.matched}
style="aspect-ratio:1;border-radius:0.75rem;font-size:1.5rem;border:1px solid {card.matched ? '#238636' : card.flipped ? '#58a6ff' : '#30363d'};background:{card.flipped || card.matched ? '#21262d' : '#161b22'};transform:{card.flipped || card.matched ? 'rotateY(0deg)' : 'rotateY(180deg)'};transition:all 0.3s;cursor:{card.matched ? 'default' : 'pointer'};user-select:none;display:flex;align-items:center;justify-content:center"
>
{card.flipped || card.matched ? card.emoji : ''}
</button>
{/each}
</div>
{#if won}
<div style="position:fixed;inset:0;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;z-index:10">
<div style="background:#161b22;border:1px solid #30363d;border-radius:1rem;padding:2rem;text-align:center;margin:1.5rem">
<p style="font-size:2.5rem;margin-bottom:0.75rem">🎉</p>
<h2 style="font-weight:700;font-size:1.25rem;margin-bottom:0.25rem">You won!</h2>
<p style="color:#8b949e;font-size:0.875rem;margin-bottom:1.5rem">Completed in {moves} moves</p>
<button
on:click={restart}
style="padding:0.625rem 1.5rem;background:#238636;border:1px solid #2ea043;color:#fff;border-radius:0.75rem;font-weight:600;font-size:0.875rem;cursor:pointer;font-family:inherit"
>Play Again</button>
</div>
</div>
{/if}
</div>
</div>Memory Card Game
An engaging brain-training game that challenges users to find matching pairs. This component showcases advanced CSS 3D transforms for card flipping and robust game logic for state management.
Features
- Responsive 4x4 card grid
- Smooth CSS 3D flip animations
- Real-time move and match tracking
- Win-state celebration with stats
- Board shuffling algorithm (Fisher-Yates)
- Themeable card backs and icons