UI Components Easy
Loyalty / Rewards Card
Customer loyalty card with 10-visit progress, animated stamps, 3-tier rewards ladder, faux barcode and member meta. Tap a stamp to flip — earn the reward at 10.
Open in Lab
MCP
html css vanilla-js
Targets: JS HTML
Code
:root {
--cream: #f5f0e8;
--cream-2: #ece4d4;
--bone: #faf7f1;
--terracotta: #c1714a;
--terracotta-d: #a05a38;
--forest: #2d4a3e;
--forest-d: #1e3329;
--gold: #c9a84c;
--gold-light: #e6c97a;
--ink: #2c1a0e;
--ink-2: #4a3828;
--warm-gray: #7a6a58;
--success: #4f7a3a;
--danger: #b3432a;
--warning: #d99020;
--font-display: "Playfair Display", Georgia, serif;
--font-body: "Inter", system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-body);
background: linear-gradient(180deg, var(--cream-2) 0%, var(--cream) 100%);
color: var(--ink);
min-height: 100vh;
display: grid;
place-items: center;
padding: 32px 16px;
-webkit-font-smoothing: antialiased;
}
.page {
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
}
.actions {
display: flex;
align-items: center;
gap: 14px;
}
.hint {
font-size: 0.84rem;
color: var(--warm-gray);
font-style: italic;
}
.quiet {
background: transparent;
border: 1px solid rgba(44, 26, 14, 0.18);
color: var(--ink-2);
border-radius: 999px;
padding: 8px 16px;
font-family: inherit;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
}
.quiet:hover {
background: var(--cream-2);
color: var(--ink);
}
.card {
perspective: 1400px;
width: 100%;
max-width: 460px;
height: 600px;
position: relative;
}
.face {
position: absolute;
inset: 0;
background: var(--bone);
border-radius: 22px;
box-shadow: 0 18px 50px rgba(44, 26, 14, 0.18);
padding: 24px 26px;
display: flex;
flex-direction: column;
gap: 14px;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
transition: transform 0.6s cubic-bezier(0.2, 0.8, 0.2, 1);
transform-style: preserve-3d;
}
.front {
background: radial-gradient(circle at 90% 0%, rgba(201, 168, 76, 0.18), transparent 60%),
var(--bone);
border: 1px solid rgba(44, 26, 14, 0.08);
}
.back {
background: linear-gradient(160deg, var(--forest) 0%, var(--forest-d) 100%);
color: var(--bone);
transform: rotateY(180deg);
align-items: center;
text-align: center;
justify-content: center;
gap: 16px;
}
.card[data-flipped="true"] .front {
transform: rotateY(180deg);
}
.card[data-flipped="true"] .back {
transform: rotateY(360deg);
}
/* Front */
.head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 14px;
}
.kicker {
font-size: 0.7rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--terracotta);
font-weight: 700;
}
.head h1 {
font-family: var(--font-display);
font-weight: 800;
font-size: 1.7rem;
letter-spacing: -0.015em;
margin-top: 2px;
}
.tier {
background: var(--gold);
color: var(--ink);
font-weight: 800;
font-size: 0.78rem;
letter-spacing: 0.16em;
text-transform: uppercase;
padding: 5px 12px;
border-radius: 999px;
align-self: flex-start;
}
.tier[data-t="bronze"] {
background: #b6724c;
color: var(--bone);
}
.tier[data-t="gold"] {
background: var(--gold);
}
.tier[data-t="silver"] {
background: linear-gradient(135deg, #d9d3c4, #b9b1a0);
color: var(--ink);
}
.member {
display: grid;
grid-template-columns: 56px 1fr auto;
align-items: center;
gap: 12px;
padding: 12px 0;
border-top: 1px dashed rgba(44, 26, 14, 0.18);
border-bottom: 1px dashed rgba(44, 26, 14, 0.18);
}
.m-avatar {
width: 56px;
height: 56px;
border-radius: 999px;
background: var(--forest);
color: var(--bone);
font-family: var(--font-display);
font-weight: 800;
font-size: 1.4rem;
display: grid;
place-items: center;
}
.m-name {
font-weight: 700;
font-size: 1.05rem;
}
.m-meta {
font-size: 0.78rem;
color: var(--warm-gray);
}
.m-points {
text-align: right;
}
.p-num {
font-family: var(--font-mono);
font-weight: 800;
font-size: 1.4rem;
color: var(--terracotta-d);
display: block;
}
.p-label {
font-size: 0.7rem;
color: var(--warm-gray);
letter-spacing: 0.14em;
text-transform: uppercase;
}
.stamp-block header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 8px;
}
.block-kicker {
font-size: 0.7rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--ink-2);
font-weight: 700;
}
.progress-text {
font-size: 0.78rem;
color: var(--warm-gray);
font-weight: 600;
}
.progress-text b {
color: var(--terracotta-d);
font-family: var(--font-mono);
}
.stamps {
list-style: none;
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
}
.stamps li {
aspect-ratio: 1 / 1;
border-radius: 999px;
background: var(--cream);
border: 1.5px dashed rgba(44, 26, 14, 0.24);
display: grid;
place-items: center;
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--warm-gray);
cursor: pointer;
transition: background 0.15s, border-color 0.15s, transform 0.15s;
}
.stamps li:hover {
border-color: var(--terracotta);
color: var(--terracotta-d);
}
.stamps li.is-stamped {
background: var(--terracotta);
color: var(--bone);
border: none;
position: relative;
animation: stamp 0.35s cubic-bezier(0.4, 1.4, 0.6, 1);
}
.stamps li.is-stamped::before {
content: "★";
font-family: var(--font-display);
font-size: 1.4rem;
}
@keyframes stamp {
from {
transform: scale(1.3) rotate(-12deg);
opacity: 0.4;
}
to {
transform: none;
opacity: 1;
}
}
.stamps li:nth-child(10) {
background: var(--gold-light);
border-style: solid;
border-color: var(--gold);
color: var(--ink);
}
.stamps li:nth-child(10).is-stamped {
background: var(--gold);
border: none;
}
.stamps li:nth-child(10)::after {
content: "🍰";
font-size: 1rem;
position: absolute;
top: -8px;
right: -8px;
background: var(--bone);
width: 22px;
height: 22px;
display: grid;
place-items: center;
border-radius: 999px;
box-shadow: 0 2px 6px rgba(44, 26, 14, 0.18);
}
.tiers {
background: var(--cream);
border: 1px solid rgba(44, 26, 14, 0.08);
border-radius: 12px;
padding: 12px 14px;
}
.tiers header {
margin-bottom: 8px;
}
.bar {
height: 6px;
background: rgba(44, 26, 14, 0.12);
border-radius: 999px;
overflow: hidden;
}
.bar span {
display: block;
height: 100%;
background: linear-gradient(90deg, #b6724c, var(--terracotta), var(--gold));
border-radius: 999px;
}
.ladder {
display: grid;
grid-template-columns: repeat(3, 1fr);
margin-top: 10px;
font-size: 0.78rem;
}
.rung {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
color: var(--warm-gray);
font-weight: 600;
}
.rung small {
font-size: 0.66rem;
color: var(--warm-gray);
font-weight: 700;
font-family: var(--font-mono);
}
.rung-dot {
width: 10px;
height: 10px;
border-radius: 999px;
background: rgba(44, 26, 14, 0.18);
}
.rung.is-done {
color: #b6724c;
}
.rung.is-done .rung-dot {
background: #b6724c;
}
.rung.is-current {
color: var(--terracotta-d);
}
.rung.is-current .rung-dot {
background: var(--terracotta);
box-shadow: 0 0 0 4px rgba(193, 113, 74, 0.2);
}
.barcode-row {
display: flex;
align-items: center;
gap: 14px;
padding-top: 12px;
border-top: 1px dashed rgba(44, 26, 14, 0.18);
}
.barcode {
display: inline-flex;
align-items: stretch;
height: 38px;
gap: 0;
}
.barcode span {
height: 100%;
}
.barcode span:nth-child(odd) {
background: var(--ink);
width: 3px;
}
.barcode span:nth-child(even) {
background: transparent;
width: 2px;
}
.barcode span:nth-child(5n) {
width: 5px;
}
.barcode span:nth-child(7n) {
width: 1px;
}
.barcode-no {
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--ink-2);
font-weight: 700;
letter-spacing: 0.04em;
}
/* Back face (reward) */
.reward-eyebrow {
font-size: 0.78rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--gold);
font-weight: 700;
}
.back h2 {
font-family: var(--font-display);
font-weight: 800;
font-size: 2rem;
letter-spacing: -0.015em;
line-height: 1.1;
}
.reward-sub {
color: rgba(245, 240, 232, 0.78);
max-width: 320px;
}
.reward-stamp {
position: relative;
width: 120px;
height: 120px;
display: grid;
place-items: center;
}
.ring {
position: absolute;
inset: 0;
border-radius: 999px;
border: 3px dashed var(--gold);
animation: rotate 18s linear infinite;
}
@keyframes rotate {
to {
transform: rotate(360deg);
}
}
.glyph {
font-size: 3.5rem;
filter: drop-shadow(0 6px 16px rgba(0, 0, 0, 0.35));
}
.reward-actions {
display: flex;
gap: 10px;
}
.ghost,
.primary {
border-radius: 999px;
padding: 11px 18px;
font-family: inherit;
font-size: 0.86rem;
font-weight: 700;
cursor: pointer;
border: 1px solid transparent;
}
.ghost {
background: transparent;
border-color: rgba(250, 247, 241, 0.3);
color: var(--bone);
}
.ghost:hover {
background: rgba(250, 247, 241, 0.12);
}
.primary {
background: var(--gold);
color: var(--ink);
}
.primary:hover {
background: var(--gold-light);
}
.reward-foot {
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--gold-light);
letter-spacing: 0.08em;
}const STAMPS = 10;
let count = 4; // start with 4 stamps already
const stamps = document.getElementById("stamps");
const card = document.getElementById("card");
const stampCount = document.getElementById("stampCount");
const hint = document.getElementById("hint");
const rewardFoot = document.getElementById("rewardFoot");
function code() {
const seg = () => Math.floor(1000 + Math.random() * 9000);
return `CASA-OLIVAR-${seg()}${String.fromCharCode(65 + Math.floor(Math.random() * 26))}`;
}
function render() {
stamps.innerHTML = Array.from({ length: STAMPS }, (_, i) => {
const stamped = i < count;
return `<li class="${stamped ? "is-stamped" : ""}" data-i="${i}">${stamped ? "" : i + 1}</li>`;
}).join("");
stampCount.textContent = count;
if (count >= STAMPS) {
card.dataset.flipped = "true";
rewardFoot.textContent = `Code · ${code()}`;
hint.textContent = "Reward unlocked — tap a button or Reset.";
} else {
card.dataset.flipped = "false";
hint.textContent =
count === 0
? "Tap each stamp to register a visit."
: `${STAMPS - count} more visit${STAMPS - count === 1 ? "" : "s"} to a free tarta.`;
}
}
stamps.addEventListener("click", (e) => {
const li = e.target.closest("[data-i]");
if (!li) return;
const i = Number(li.dataset.i);
if (i === count) {
count = Math.min(STAMPS, count + 1);
render();
} else if (i < count) {
// Tap an existing stamp to "undo" the most recent (only the latest)
if (i === count - 1) {
count -= 1;
render();
}
}
});
document.getElementById("resetBtn").addEventListener("click", () => {
count = 0;
render();
});
document.getElementById("laterBtn").addEventListener("click", () => {
hint.textContent = "Saved · we'll remind you on your next visit.";
});
document.getElementById("redeemBtn").addEventListener("click", () => {
hint.textContent = "Redeemed · enjoy the tarta!";
count = 0;
render();
});
render();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,700;0,800;1,500&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap"
/>
<link rel="stylesheet" href="style.css" />
<title>Loyalty · Casa Olivar</title>
</head>
<body>
<main class="page">
<div class="card" id="card" data-flipped="false">
<!-- Front face -->
<article class="face front">
<header class="head">
<div>
<p class="kicker">Casa Olivar · Loyalty</p>
<h1>The Hearth Card</h1>
</div>
<span class="tier" id="tier">Silver</span>
</header>
<div class="member">
<div class="m-avatar">L</div>
<div>
<p class="m-name">Lina Mendoza</p>
<p class="m-meta">Member since · May 2024 · #00184</p>
</div>
<div class="m-points">
<span class="p-num" id="points">2,460</span>
<span class="p-label">points</span>
</div>
</div>
<section class="stamp-block">
<header>
<p class="block-kicker">Visit progress</p>
<p class="progress-text"><b id="stampCount">0</b> / 10 · earn a free dessert</p>
</header>
<ol class="stamps" id="stamps"></ol>
</section>
<section class="tiers">
<header>
<p class="block-kicker">Next tier · $40 to Gold</p>
</header>
<div class="bar"><span style="width: 68%"></span></div>
<div class="ladder">
<div class="rung is-done"><span class="rung-dot"></span><span>Bronze</span><small>$0</small></div>
<div class="rung is-current"><span class="rung-dot"></span><span>Silver</span><small>$250</small></div>
<div class="rung"><span class="rung-dot"></span><span>Gold</span><small>$500</small></div>
</div>
</section>
<footer class="barcode-row">
<span class="barcode" aria-hidden="true">
<span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span>
</span>
<span class="barcode-no">5· 4012 · 0184 · L</span>
</footer>
</article>
<!-- Back face (reward) -->
<article class="face back">
<p class="reward-eyebrow">★ Reward unlocked</p>
<h2>Burnt cheesecake,<br />on us.</h2>
<p class="reward-sub">
Show this card to your server tonight.<br />
Valid for one tarta de queso quemada · expires 30 days.
</p>
<div class="reward-stamp">
<span class="ring"></span>
<span class="glyph">🍰</span>
</div>
<div class="reward-actions">
<button class="ghost" type="button" id="laterBtn">Save for later</button>
<button class="primary" type="button" id="redeemBtn">Redeem now</button>
</div>
<p class="reward-foot" id="rewardFoot">Code · CASA-OLIVAR-DC2046</p>
</article>
</div>
<div class="actions no-print">
<button class="quiet" type="button" id="resetBtn">Reset card</button>
<p class="hint" id="hint">Tap each stamp to register a visit.</p>
</div>
</main>
<script src="script.js"></script>
</body>
</html>Loyalty Card
The wallet-pass card a regular shows the host. Member name, points balance, tier (Bronze · Silver · Gold), 10-stamp visit progress with an “Earn dessert” reward at 10, a 3-tier ladder showing the next reward, and a CSS faux-barcode for scanning. Tap a stamp to add it; the 10th flips the card to a “Reward unlocked” face with redeem CTA.