Shop — Product Card
A reusable e-commerce product card shown as a four-up grid of fictional storefront items. Each card pairs a CSS-gradient and inline-SVG product silhouette with a wishlist heart, a Sale or New badge, brand and title, a star rating with review count, colour swatch dots, and a sale price with compare-at strikethrough. Hover reveals a Quick add flyout with a size picker; swatches preview the image tint, the heart toggles, and a sold-out variant shows a Notify me state. Pure vanilla JS, no images or external libraries.
MCP
Code
:root {
--bg: #ffffff;
--panel: #f6f7fb;
--ink: #16181d;
--muted: #6b7280;
--brand: #3457ff;
--brand-d: #2742d6;
--sale: #e0245e;
--ok: #1f9d55;
--line: rgba(16, 18, 29, .1);
--line-2: rgba(16, 18, 29, .06);
--shadow: 0 1px 2px rgba(16, 18, 29, .06), 0 12px 30px -16px rgba(16, 18, 29, .28);
--radius: 16px;
/* swatch tile tints — paired tint + ink per colour */
--indigo: #5b6bff; --indigo-bg: linear-gradient(150deg, #eef0ff, #dfe3ff 60%, #ccd2ff);
--teal: #159c8f; --teal-bg: linear-gradient(150deg, #e4f7f4, #cdeee9 60%, #b6e6df);
--rose: #e0245e; --rose-bg: linear-gradient(150deg, #ffe9f0, #ffd5e1 60%, #ffc1d3);
--amber: #d98a16; --amber-bg: linear-gradient(150deg, #fdf2dd, #fbe7bd 60%, #f8dca0);
--slate: #3a4252; --slate-bg: linear-gradient(150deg, #eef0f4, #dde1e9 60%, #ccd2dd);
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background:
radial-gradient(1100px 520px at 12% -8%, #f1f3ff, transparent 60%),
radial-gradient(900px 480px at 108% 0%, #eafaf6, transparent 55%),
var(--bg);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a { color: inherit; }
.skip {
position: absolute;
left: 12px;
top: -48px;
z-index: 50;
background: var(--ink);
color: #fff;
padding: 9px 14px;
border-radius: 10px;
font-weight: 600;
text-decoration: none;
transition: top .15s ease;
}
.skip:focus-visible { top: 12px; }
:focus-visible {
outline: 3px solid var(--brand);
outline-offset: 2px;
border-radius: 8px;
}
/* ---------- masthead ---------- */
.masthead {
border-bottom: 1px solid var(--line);
background: rgba(255, 255, 255, .8);
backdrop-filter: saturate(1.4) blur(8px);
position: sticky;
top: 0;
z-index: 20;
}
.masthead__in {
max-width: 1120px;
margin: 0 auto;
padding: 16px 22px;
display: flex;
align-items: center;
gap: 18px;
}
.brand {
display: inline-flex;
align-items: center;
gap: 9px;
font-weight: 800;
letter-spacing: -.02em;
font-size: 19px;
text-decoration: none;
color: var(--ink);
}
.brand svg { color: var(--brand); }
.masthead__tag {
margin: 0;
color: var(--muted);
font-size: 13.5px;
flex: 1;
}
.bag {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border: 1px solid var(--line);
background: #fff;
border-radius: 12px;
color: var(--ink);
cursor: pointer;
transition: border-color .15s ease, transform .12s ease;
}
.bag:hover { border-color: var(--brand); transform: translateY(-1px); }
.bag__count {
position: absolute;
top: -7px;
right: -7px;
min-width: 20px;
height: 20px;
padding: 0 5px;
display: grid;
place-items: center;
background: var(--brand);
color: #fff;
font-size: 11.5px;
font-weight: 700;
border-radius: 999px;
border: 2px solid #fff;
transition: transform .2s cubic-bezier(.34, 1.56, .64, 1);
}
.bag__count.is-pop { transform: scale(1.35); }
/* ---------- layout ---------- */
.wrap {
max-width: 1120px;
margin: 0 auto;
padding: 38px 22px 60px;
}
.lede h1 {
margin: 0 0 6px;
font-size: clamp(24px, 3vw, 32px);
letter-spacing: -.025em;
}
.lede p { margin: 0 0 26px; color: var(--muted); max-width: 56ch; }
.cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 22px;
}
/* ---------- card ---------- */
.card {
display: flex;
flex-direction: column;
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow);
transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease;
}
.card:hover {
transform: translateY(-4px);
border-color: rgba(52, 87, 255, .35);
box-shadow: 0 2px 4px rgba(16, 18, 29, .06), 0 24px 44px -20px rgba(52, 87, 255, .4);
}
.card.is-soldout { opacity: .96; }
.card.is-soldout:hover { transform: none; box-shadow: var(--shadow); border-color: var(--line); }
.card__media {
position: relative;
isolation: isolate;
}
.shot {
aspect-ratio: 5 / 4;
display: grid;
place-items: center;
transition: background .35s ease, color .35s ease;
}
.shot--indigo { background: var(--indigo-bg); color: var(--indigo); }
.shot--teal { background: var(--teal-bg); color: var(--teal); }
.shot--rose { background: var(--rose-bg); color: var(--rose); }
.shot--amber { background: var(--amber-bg); color: var(--amber); }
.shot--slate { background: var(--slate-bg); color: var(--slate); }
.shot__svg {
width: 78%;
height: 78%;
filter: drop-shadow(0 14px 18px rgba(16, 18, 29, .16));
transition: transform .35s ease;
}
.card:hover .shot__svg { transform: scale(1.04) rotate(-1.5deg); }
.is-soldout .shot { filter: grayscale(.55) opacity(.85); }
.is-soldout .card__media::after {
content: "";
position: absolute;
inset: 0;
background: rgba(255, 255, 255, .12);
pointer-events: none;
}
/* badge */
.badge {
position: absolute;
top: 12px;
left: 12px;
z-index: 3;
font-size: 11.5px;
font-weight: 700;
letter-spacing: .03em;
text-transform: uppercase;
padding: 4px 9px;
border-radius: 999px;
color: #fff;
}
.badge--sale { background: var(--sale); }
.badge--new { background: var(--ink); }
.badge--out { background: #4b5563; }
/* wishlist heart */
.wish {
position: absolute;
top: 10px;
right: 10px;
z-index: 3;
width: 38px;
height: 38px;
display: grid;
place-items: center;
border: none;
border-radius: 999px;
background: rgba(255, 255, 255, .92);
box-shadow: 0 2px 8px rgba(16, 18, 29, .14);
cursor: pointer;
transition: transform .12s ease, background .15s ease;
}
.wish svg { width: 19px; height: 19px; fill: none; stroke: var(--muted); stroke-width: 1.8; transition: fill .15s ease, stroke .15s ease; }
.wish:hover { transform: scale(1.08); }
.wish:hover svg { stroke: var(--sale); }
.wish[aria-pressed="true"] svg { fill: var(--sale); stroke: var(--sale); }
.wish.is-burst { animation: burst .4s cubic-bezier(.34, 1.56, .64, 1); }
@keyframes burst {
0% { transform: scale(1); }
45% { transform: scale(1.3); }
100% { transform: scale(1); }
}
/* quick add flyout */
.quick {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 2;
padding: 12px;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
background: linear-gradient(to top, rgba(255, 255, 255, .97), rgba(255, 255, 255, .82));
border-top: 1px solid var(--line-2);
transform: translateY(102%);
opacity: 0;
transition: transform .25s ease, opacity .25s ease;
}
.card:hover .quick,
.card:focus-within .quick { transform: translateY(0); opacity: 1; }
.quick__label {
font-size: 12.5px;
font-weight: 700;
color: var(--ink);
letter-spacing: .01em;
}
.sizes { display: flex; gap: 6px; flex-wrap: wrap; }
.size {
min-width: 34px;
height: 30px;
padding: 0 9px;
border: 1px solid var(--line);
background: #fff;
border-radius: 8px;
font: inherit;
font-size: 12.5px;
font-weight: 600;
color: var(--ink);
cursor: pointer;
transition: border-color .12s ease, background .12s ease, color .12s ease;
}
.size:hover { border-color: var(--brand); color: var(--brand-d); }
.size:active { background: var(--brand); color: #fff; border-color: var(--brand); }
.size--out {
color: var(--muted);
cursor: not-allowed;
text-decoration: line-through;
background: var(--panel);
}
/* ---------- card body ---------- */
.card__body { padding: 16px 16px 18px; display: flex; flex-direction: column; gap: 8px; }
.brand-line {
margin: 0;
font-size: 11.5px;
font-weight: 600;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--muted);
}
.title { margin: 0; font-size: 16px; font-weight: 700; letter-spacing: -.01em; }
.title a { text-decoration: none; }
.title a:hover { color: var(--brand-d); text-decoration: underline; text-underline-offset: 3px; }
.rating { display: flex; align-items: center; gap: 6px; font-size: 13px; }
.stars {
--fill: 100%;
position: relative;
display: inline-block;
width: 86px;
height: 15px;
background:
linear-gradient(90deg, #f4b740 var(--fill), #d8dbe2 var(--fill));
-webkit-mask: repeating-linear-gradient(90deg, #000 0 13px, transparent 13px 17.2px);
mask: repeating-linear-gradient(90deg, #000 0 13px, transparent 13px 17.2px);
}
.stars::before {
content: "★★★★★";
position: absolute;
inset: 0;
font-size: 16px;
letter-spacing: 2.2px;
line-height: 15px;
color: transparent;
}
.rating__n { font-weight: 700; }
.rating__c { color: var(--muted); }
/* swatches */
.swatches { display: flex; gap: 8px; margin-top: 2px; }
.dot {
width: 22px;
height: 22px;
padding: 0;
border: 2px solid #fff;
border-radius: 999px;
background: var(--dot);
box-shadow: 0 0 0 1px var(--line);
cursor: pointer;
transition: transform .12s ease, box-shadow .12s ease;
}
.dot:hover { transform: scale(1.12); }
.dot[aria-pressed="true"] { box-shadow: 0 0 0 2px var(--ink); transform: scale(1.05); }
/* price */
.price { display: flex; align-items: baseline; gap: 9px; margin-top: 4px; flex-wrap: wrap; }
.price__now { font-size: 19px; font-weight: 800; letter-spacing: -.01em; }
.price__was { font-size: 14px; color: var(--muted); text-decoration: line-through; }
.price__off { font-size: 12px; font-weight: 700; color: var(--sale); }
.stock { font-size: 12px; font-weight: 700; }
.stock--out { color: var(--muted); }
/* trust strip */
.trust {
margin: 34px 0 0;
display: flex;
align-items: center;
justify-content: center;
gap: 9px;
color: var(--muted);
font-size: 13px;
font-weight: 500;
}
.trust svg { color: var(--ok); }
/* ---------- toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 24px);
z-index: 60;
background: var(--ink);
color: #fff;
padding: 12px 18px;
border-radius: 12px;
font-size: 14px;
font-weight: 600;
box-shadow: 0 14px 36px -10px rgba(16, 18, 29, .5);
opacity: 0;
pointer-events: none;
transition: opacity .22s ease, transform .22s ease;
max-width: calc(100% - 32px);
}
.toast.is-on { opacity: 1; transform: translate(-50%, 0); }
/* ---------- responsive ---------- */
@media (max-width: 1000px) {
.cards { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 560px) {
.masthead__tag { display: none; }
.wrap { padding: 26px 16px 48px; }
.cards { grid-template-columns: 1fr; gap: 18px; }
/* on touch, keep quick add visible since there is no hover */
.quick { position: static; transform: none; opacity: 1; background: var(--panel); }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition: none !important; animation: none !important; }
}(function () {
"use strict";
var TINTS = ["indigo", "teal", "rose", "amber", "slate"];
/* ---------- toast helper ---------- */
var toastEl = document.getElementById("toast");
var toastTimer = null;
function toast(msg) {
if (!toastEl) return;
toastEl.textContent = msg;
toastEl.classList.add("is-on");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("is-on");
}, 2400);
}
function money(n) {
return "$" + Number(n).toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
}
/* ---------- cart badge ---------- */
var bagCountEl = document.getElementById("bagCount");
var bagCount = 0;
function addToBag(qty) {
bagCount += qty || 1;
bagCountEl.textContent = String(bagCount);
bagCountEl.classList.remove("is-pop");
/* reflow to restart the pop animation */
void bagCountEl.offsetWidth;
bagCountEl.classList.add("is-pop");
}
document.getElementById("bagBtn").addEventListener("click", function () {
toast(bagCount === 0 ? "Your bag is empty." : "Bag · " + bagCount + " item" + (bagCount === 1 ? "" : "s"));
});
/* ---------- per-card wiring ---------- */
var cards = document.querySelectorAll(".card");
cards.forEach(function (card) {
var name = card.getAttribute("data-product");
var price = Number(card.getAttribute("data-price"));
var soldOut = card.classList.contains("is-soldout");
var shot = card.querySelector(".shot");
var swatches = card.querySelectorAll(".dot");
/* swatch: hover previews the tint, click locks it in */
var lockedColor = card.getAttribute("data-color");
function applyTint(color) {
if (!shot || TINTS.indexOf(color) === -1) return;
TINTS.forEach(function (c) { shot.classList.remove("shot--" + c); });
shot.classList.add("shot--" + color);
}
swatches.forEach(function (dot) {
var color = dot.getAttribute("data-color");
dot.addEventListener("mouseenter", function () { applyTint(color); });
dot.addEventListener("focus", function () { applyTint(color); });
dot.addEventListener("mouseleave", function () { applyTint(lockedColor); });
dot.addEventListener("blur", function () { applyTint(lockedColor); });
dot.addEventListener("click", function () {
lockedColor = color;
card.setAttribute("data-color", color);
applyTint(color);
swatches.forEach(function (d) { d.setAttribute("aria-pressed", "false"); });
dot.setAttribute("aria-pressed", "true");
toast(name + " — " + dot.getAttribute("data-name"));
});
});
/* roving arrow-key navigation across swatches */
card.querySelector(".swatches").addEventListener("keydown", function (e) {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
var list = Array.prototype.slice.call(swatches);
var i = list.indexOf(document.activeElement);
if (i === -1) i = 0;
var next = e.key === "ArrowRight"
? (i + 1) % list.length
: (i - 1 + list.length) % list.length;
list[next].focus();
});
/* wishlist heart toggle */
var wish = card.querySelector(".wish");
if (wish) {
wish.addEventListener("click", function () {
var on = wish.getAttribute("aria-pressed") === "true";
wish.setAttribute("aria-pressed", String(!on));
wish.classList.remove("is-burst");
void wish.offsetWidth;
if (!on) wish.classList.add("is-burst");
toast(!on ? "Saved " + name + " to wishlist" : "Removed " + name + " from wishlist");
});
}
/* quick-add size buttons */
var sizeBtns = card.querySelectorAll(".size:not([disabled])");
sizeBtns.forEach(function (btn) {
btn.addEventListener("click", function () {
if (soldOut) return;
var size = btn.getAttribute("data-size");
var colorName = card.querySelector('.dot[aria-pressed="true"]');
var colorLabel = colorName ? colorName.getAttribute("data-name") : "";
addToBag(1);
toast("Added " + name + " · " + colorLabel + " · " + size + " — " + money(price));
});
});
/* sold-out: notify-me */
if (soldOut) {
var notify = card.querySelector(".quick__label");
if (notify) {
notify.style.cursor = "pointer";
notify.setAttribute("role", "button");
notify.setAttribute("tabindex", "0");
var doNotify = function () { toast("We'll email you when " + name + " is back in stock."); };
notify.addEventListener("click", doNotify);
notify.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); doNotify(); }
});
}
}
});
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Loomhaus — Product Cards</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<a class="skip" href="#grid">Skip to products</a>
<header class="masthead" role="banner">
<div class="masthead__in">
<a class="brand" href="#" aria-label="Loomhaus home">
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M4 9l8-5 8 5v10l-8 5-8-5V9z" fill="none" stroke="currentColor" stroke-width="1.6" />
<path d="M4 9l8 5 8-5M12 14v10" fill="none" stroke="currentColor" stroke-width="1.6" />
</svg>
<span>Loomhaus</span>
</a>
<p class="masthead__tag">The Product Card — a reusable commerce primitive.</p>
<button class="bag" type="button" id="bagBtn" aria-label="Open shopping bag">
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M6 8h12l-1 12H7L6 8z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round" />
<path d="M9 8a3 3 0 0 1 6 0" fill="none" stroke="currentColor" stroke-width="1.7" />
</svg>
<span class="bag__count" id="bagCount" aria-live="polite">0</span>
</button>
</div>
</header>
<main class="wrap" id="grid">
<div class="lede">
<h1>Featured this week</h1>
<p>Hover a card for Quick add, pick a colour, save a favourite. Four variants — all interactive.</p>
</div>
<section class="cards" aria-label="Featured products">
<!-- Card 1 — Sale -->
<article class="card" data-product="Cloudstep Runner" data-price="98" data-color="indigo">
<div class="card__media">
<span class="badge badge--sale">Sale</span>
<button class="wish" type="button" aria-pressed="false" aria-label="Save Cloudstep Runner to wishlist">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 21s-7.5-4.6-10-9.2C.3 8.6 1.6 5 5 5c2 0 3.2 1.1 4 2.3C9.8 6.1 11 5 13 5c3.4 0 4.7 3.6 3 6.8C19.5 16.4 12 21 12 21z"/></svg>
</button>
<div class="shot shot--indigo" role="img" aria-label="Indigo running shoe">
<svg viewBox="0 0 200 120" aria-hidden="true" focusable="false" class="shot__svg">
<path d="M18 78c10-3 24-6 38-18 8-7 18-12 30-12 10 0 16 4 26 10 14 8 36 10 56 12 6 1 10 5 10 10 0 6-5 9-12 9H30c-9 0-16-4-16-11 0-4 1-7 4-10z" fill="currentColor" opacity=".92"/>
<path d="M40 70c14-2 30-8 46-20" fill="none" stroke="rgba(255,255,255,.55)" stroke-width="3" stroke-linecap="round"/>
<circle cx="58" cy="86" r="3.4" fill="rgba(255,255,255,.7)"/>
<circle cx="86" cy="86" r="3.4" fill="rgba(255,255,255,.7)"/>
<circle cx="114" cy="86" r="3.4" fill="rgba(255,255,255,.7)"/>
</svg>
</div>
<div class="quick">
<span class="quick__label">Quick add</span>
<div class="sizes" role="group" aria-label="Choose a size for Cloudstep Runner">
<button class="size" type="button" data-size="7">7</button>
<button class="size" type="button" data-size="8">8</button>
<button class="size" type="button" data-size="9">9</button>
<button class="size" type="button" data-size="10">10</button>
<button class="size size--out" type="button" data-size="11" disabled aria-disabled="true">11</button>
</div>
</div>
</div>
<div class="card__body">
<p class="brand-line">Loomhaus Athletics</p>
<h2 class="title"><a href="#">Cloudstep Runner</a></h2>
<div class="rating" aria-label="Rated 4.8 out of 5 from 214 reviews">
<span class="stars" style="--fill:96%" aria-hidden="true"></span>
<span class="rating__n">4.8</span><span class="rating__c">(214)</span>
</div>
<div class="swatches" role="group" aria-label="Colours for Cloudstep Runner">
<button class="dot" type="button" data-color="indigo" data-name="Indigo" aria-pressed="true" style="--dot:#5b6bff" aria-label="Indigo"></button>
<button class="dot" type="button" data-color="teal" data-name="Teal" aria-pressed="false" style="--dot:#159c8f" aria-label="Teal"></button>
<button class="dot" type="button" data-color="rose" data-name="Rose" aria-pressed="false" style="--dot:#e0245e" aria-label="Rose"></button>
<button class="dot" type="button" data-color="slate" data-name="Slate" aria-pressed="false" style="--dot:#3a4252" aria-label="Slate"></button>
</div>
<div class="price">
<span class="price__now">$98.00</span>
<span class="price__was">$140.00</span>
<span class="price__off">−30%</span>
</div>
</div>
</article>
<!-- Card 2 — New -->
<article class="card" data-product="Meridian Tote" data-price="164" data-color="teal">
<div class="card__media">
<span class="badge badge--new">New</span>
<button class="wish" type="button" aria-pressed="false" aria-label="Save Meridian Tote to wishlist">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 21s-7.5-4.6-10-9.2C.3 8.6 1.6 5 5 5c2 0 3.2 1.1 4 2.3C9.8 6.1 11 5 13 5c3.4 0 4.7 3.6 3 6.8C19.5 16.4 12 21 12 21z"/></svg>
</button>
<div class="shot shot--teal" role="img" aria-label="Teal leather tote bag">
<svg viewBox="0 0 200 120" aria-hidden="true" focusable="false" class="shot__svg">
<path d="M58 44c0-12 8-20 18-20s18 8 18 20" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>
<path d="M44 44h64l8 62H36l8-62z" fill="currentColor" opacity=".92"/>
<path d="M44 44h64" stroke="rgba(255,255,255,.4)" stroke-width="3"/>
<rect x="66" y="70" width="20" height="6" rx="3" fill="rgba(255,255,255,.6)"/>
</svg>
</div>
<div class="quick">
<span class="quick__label">Quick add</span>
<div class="sizes" role="group" aria-label="Choose a size for Meridian Tote">
<button class="size" type="button" data-size="One size">One size</button>
</div>
</div>
</div>
<div class="card__body">
<p class="brand-line">Loomhaus Carry</p>
<h2 class="title"><a href="#">Meridian Tote</a></h2>
<div class="rating" aria-label="Rated 4.9 out of 5 from 87 reviews">
<span class="stars" style="--fill:98%" aria-hidden="true"></span>
<span class="rating__n">4.9</span><span class="rating__c">(87)</span>
</div>
<div class="swatches" role="group" aria-label="Colours for Meridian Tote">
<button class="dot" type="button" data-color="teal" data-name="Teal" aria-pressed="true" style="--dot:#159c8f" aria-label="Teal"></button>
<button class="dot" type="button" data-color="amber" data-name="Amber" aria-pressed="false" style="--dot:#d98a16" aria-label="Amber"></button>
<button class="dot" type="button" data-color="slate" data-name="Slate" aria-pressed="false" style="--dot:#3a4252" aria-label="Slate"></button>
</div>
<div class="price">
<span class="price__now">$164.00</span>
</div>
</div>
</article>
<!-- Card 3 — plain -->
<article class="card" data-product="Aero Mug 350ml" data-price="29" data-color="amber">
<div class="card__media">
<button class="wish" type="button" aria-pressed="false" aria-label="Save Aero Mug to wishlist">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 21s-7.5-4.6-10-9.2C.3 8.6 1.6 5 5 5c2 0 3.2 1.1 4 2.3C9.8 6.1 11 5 13 5c3.4 0 4.7 3.6 3 6.8C19.5 16.4 12 21 12 21z"/></svg>
</button>
<div class="shot shot--amber" role="img" aria-label="Amber ceramic travel mug">
<svg viewBox="0 0 200 120" aria-hidden="true" focusable="false" class="shot__svg">
<rect x="62" y="30" width="64" height="70" rx="14" fill="currentColor" opacity=".92"/>
<path d="M126 48c16 0 16 24 0 24" fill="none" stroke="currentColor" stroke-width="7"/>
<rect x="62" y="30" width="64" height="12" rx="6" fill="rgba(255,255,255,.45)"/>
<rect x="74" y="58" width="22" height="5" rx="2.5" fill="rgba(255,255,255,.6)"/>
</svg>
</div>
<div class="quick">
<span class="quick__label">Quick add</span>
<div class="sizes" role="group" aria-label="Choose a size for Aero Mug">
<button class="size" type="button" data-size="350ml">350ml</button>
<button class="size" type="button" data-size="500ml">500ml</button>
</div>
</div>
</div>
<div class="card__body">
<p class="brand-line">Loomhaus Home</p>
<h2 class="title"><a href="#">Aero Mug 350ml</a></h2>
<div class="rating" aria-label="Rated 4.5 out of 5 from 412 reviews">
<span class="stars" style="--fill:90%" aria-hidden="true"></span>
<span class="rating__n">4.5</span><span class="rating__c">(412)</span>
</div>
<div class="swatches" role="group" aria-label="Colours for Aero Mug">
<button class="dot" type="button" data-color="amber" data-name="Amber" aria-pressed="true" style="--dot:#d98a16" aria-label="Amber"></button>
<button class="dot" type="button" data-color="indigo" data-name="Indigo" aria-pressed="false" style="--dot:#5b6bff" aria-label="Indigo"></button>
<button class="dot" type="button" data-color="rose" data-name="Rose" aria-pressed="false" style="--dot:#e0245e" aria-label="Rose"></button>
</div>
<div class="price">
<span class="price__now">$29.00</span>
</div>
</div>
</article>
<!-- Card 4 — sold out -->
<article class="card is-soldout" data-product="Trailhead Cap" data-price="42" data-color="slate" aria-disabled="true">
<div class="card__media">
<span class="badge badge--out">Sold out</span>
<button class="wish" type="button" aria-pressed="false" aria-label="Save Trailhead Cap to wishlist">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 21s-7.5-4.6-10-9.2C.3 8.6 1.6 5 5 5c2 0 3.2 1.1 4 2.3C9.8 6.1 11 5 13 5c3.4 0 4.7 3.6 3 6.8C19.5 16.4 12 21 12 21z"/></svg>
</button>
<div class="shot shot--slate" role="img" aria-label="Slate canvas cap">
<svg viewBox="0 0 200 120" aria-hidden="true" focusable="false" class="shot__svg">
<path d="M40 78c0-30 24-44 60-44s60 14 60 30c0 6-6 10-14 10H40z" fill="currentColor" opacity=".92"/>
<path d="M40 78c-14 2-26 6-26 14 0 4 4 6 10 6h62" fill="none" stroke="currentColor" stroke-width="9" stroke-linecap="round"/>
<path d="M70 50c20-8 44-8 64 2" fill="none" stroke="rgba(255,255,255,.45)" stroke-width="3"/>
</svg>
</div>
<div class="quick">
<span class="quick__label">Notify me</span>
</div>
</div>
<div class="card__body">
<p class="brand-line">Loomhaus Outdoor</p>
<h2 class="title"><a href="#">Trailhead Cap</a></h2>
<div class="rating" aria-label="Rated 4.6 out of 5 from 153 reviews">
<span class="stars" style="--fill:92%" aria-hidden="true"></span>
<span class="rating__n">4.6</span><span class="rating__c">(153)</span>
</div>
<div class="swatches" role="group" aria-label="Colours for Trailhead Cap">
<button class="dot" type="button" data-color="slate" data-name="Slate" aria-pressed="true" style="--dot:#3a4252" aria-label="Slate"></button>
<button class="dot" type="button" data-color="teal" data-name="Teal" aria-pressed="false" style="--dot:#159c8f" aria-label="Teal"></button>
</div>
<div class="price">
<span class="price__now">$42.00</span>
<span class="stock stock--out">Out of stock</span>
</div>
</div>
</article>
</section>
<p class="trust">
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 2l8 3v6c0 5-3.4 9-8 11-4.6-2-8-6-8-11V5l8-3z" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M8.5 12l2.5 2.5 4.5-5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>
Secure checkout · Free shipping over $75 · 30-day returns
</p>
</main>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Product Card
The core commerce primitive, rendered four ways in a responsive grid. Every card is built from the same parts: a soft tinted media tile holding an inline-SVG product silhouette (shoe, tote, mug, cap), a corner badge for Sale, New, or Sold out, and a floating wishlist heart. Below the image sit the brand eyebrow, a linked title, a star rating with its review count, a row of colour swatch dots, and a prominent price — with a compare-at strikethrough and savings chip when the item is on sale.
Interactions are all real. Hovering or focusing a card slides up a Quick add flyout with a size picker; choosing a size pushes a toast and bumps the header bag badge. Swatch dots preview their colour by re-tinting the product image on hover or keyboard focus, and clicking one locks the choice in. The wishlist heart toggles with a little burst animation, and the sold-out card swaps its sizes for a keyboard-operable Notify me control instead of an Add action.
Swatch groups support arrow-key roving, controls expose aria-pressed and live regions, focus-visible rings stay sharp, and the grid collapses from four columns to two to one. On touch widths the Quick add panel stays pinned open since there is no hover, and a reduced-motion fallback disables the transitions.
Illustrative storefront UI only — fictional products, prices, and reviews. No real checkout.