Product card
An e-commerce card with image, price, rating and an add-to-basket action.
<div class="grid">
<article class="card">
<a class="media" href="/p/ellery-shirt/">
<img src="https://placehold.co/600x750/e8ecf4/8593ab?text=Linen+shirt" alt="Oatmeal linen shirt, folded">
<span class="badge">New</span>
</a>
<div class="body">
<h3><a href="/p/ellery-shirt/">Ellery linen shirt</a></h3>
<p class="sub">Oatmeal · Unisex</p>
<div class="rating" aria-label="Rated 4.5 out of 5 from 128 reviews">
<span class="stars" style="--pct:90%" aria-hidden="true">★★★★★</span>
<span class="count">128</span>
</div>
<p class="price"><span class="now">£58</span> <s class="was">£72</s></p>
<button class="buy" type="button">Add to basket</button>
</div>
</article>
<article class="card">
<a class="media" href="/p/wool-scarf/">
<img src="https://placehold.co/600x750/e6efe9/7ba88f?text=Wool+scarf" alt="Green lambswool scarf">
</a>
<div class="body">
<h3><a href="/p/wool-scarf/">Lambswool scarf</a></h3>
<p class="sub">Moss · One size</p>
<div class="rating" aria-label="Rated 5 out of 5 from 41 reviews">
<span class="stars" style="--pct:100%" aria-hidden="true">★★★★★</span>
<span class="count">41</span>
</div>
<p class="price"><span class="now">£34</span></p>
<button class="buy" type="button">Add to basket</button>
</div>
</article>
<article class="card">
<a class="media" href="/p/canvas-tote/">
<img src="https://placehold.co/600x750/f3ecdf/b79a6a?text=Canvas+tote" alt="Natural canvas tote bag">
<span class="badge out">Out of stock</span>
</a>
<div class="body">
<h3><a href="/p/canvas-tote/">Canvas tote</a></h3>
<p class="sub">Natural · 14oz</p>
<div class="rating" aria-label="Rated 4 out of 5 from 9 reviews">
<span class="stars" style="--pct:80%" aria-hidden="true">★★★★★</span>
<span class="count">9</span>
</div>
<p class="price"><span class="now">£26</span></p>
<button class="buy" type="button" disabled>Notify me</button>
</div>
</article>
</div>* { box-sizing: border-box; margin: 0; }
body { font: 16px/1.6 system-ui, -apple-system, sans-serif; background: #f6f8fb; padding: 24px; }
.grid {
display: grid;
gap: 18px;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
}
.card {
display: flex;
flex-direction: column;
background: #fff;
border: 1px solid #e2e7ef;
border-radius: 12px;
overflow: hidden;
transition: border-color .15s ease, transform .15s ease;
}
.card:hover { border-color: #c9d3e2; transform: translateY(-2px); }
.media { position: relative; display: block; background: #eef1f6; }
.media img {
display: block;
width: 100%;
aspect-ratio: 4 / 5;
object-fit: cover;
}
.badge {
position: absolute;
top: 10px;
left: 10px;
background: #16202e;
color: #fff;
font-size: .72rem;
padding: 4px 9px;
border-radius: 999px;
}
.badge.out { background: #8593ab; }
.body { padding: 14px 15px 16px; display: flex; flex-direction: column; flex: 1; }
.body h3 { font-size: .98rem; font-weight: 600; line-height: 1.35; }
.body h3 a { color: #16202e; text-decoration: none; }
.body h3 a:hover { color: #3b4fe4; }
.sub { font-size: .82rem; color: #8593ab; margin-top: 2px; }
.rating { display: flex; align-items: center; gap: 7px; margin-top: 8px; }
/* one gradient-clipped span instead of five icons */
.stars {
font-size: .95rem;
letter-spacing: 1px;
background: linear-gradient(90deg, #e8a33d var(--pct), #d9dfe9 var(--pct));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.count { font-size: .78rem; color: #8593ab; }
.price { margin-top: 10px; display: flex; align-items: baseline; gap: 8px; }
.now { font-size: 1.1rem; font-weight: 600; color: #16202e; }
.was { font-size: .86rem; color: #8593ab; }
.buy {
margin-top: 14px;
width: 100%;
font: inherit;
font-size: .9rem;
font-weight: 500;
padding: 10px;
border: 0;
border-radius: 8px;
background: #3b4fe4;
color: #fff;
cursor: pointer;
}
.buy:hover { background: #2a3abf; }
.buy:disabled { background: #eef1f6; color: #8593ab; cursor: not-allowed; }
@media (prefers-reduced-motion: reduce) {
.card { transition: none; }
.card:hover { transform: none; }
}<article class="flex flex-col overflow-hidden rounded-xl border border-slate-200 bg-white transition hover:-translate-y-0.5 hover:border-slate-300">
<a class="relative block bg-slate-100" href="/p/ellery-shirt/">
<img class="aspect-[4/5] w-full object-cover"
src="https://placehold.co/600x750/e8ecf4/8593ab?text=Linen+shirt"
alt="Oatmeal linen shirt, folded">
<span class="absolute left-2.5 top-2.5 rounded-full bg-slate-900 px-2.5 py-1 text-xs text-white">New</span>
</a>
<div class="flex flex-1 flex-col p-4">
<h3 class="text-base font-semibold leading-snug">
<a class="text-slate-900 hover:text-indigo-600" href="/p/ellery-shirt/">Ellery linen shirt</a>
</h3>
<p class="mt-0.5 text-sm text-slate-400">Oatmeal · Unisex</p>
<div class="mt-2 flex items-center gap-2" aria-label="Rated 4.5 out of 5 from 128 reviews">
<span class="text-amber-500" aria-hidden="true">★★★★<span class="text-slate-300">★</span></span>
<span class="text-xs text-slate-400">128</span>
</div>
<p class="mt-2.5 flex items-baseline gap-2">
<span class="text-lg font-semibold text-slate-900">£58</span>
<s class="text-sm text-slate-400">£72</s>
</p>
<button type="button"
class="mt-3.5 w-full rounded-lg bg-indigo-600 py-2.5 text-sm font-medium text-white hover:bg-indigo-700">
Add to basket
</button>
</div>
</article>How it works
aspect-ratio holds the layout still. Setting aspect-ratio: 4 / 5 on the image means the browser reserves the right space before the image arrives, so the grid does not jump when it loads. That jump is measured as Cumulative Layout Shift and it is a Core Web Vitals metric.
The stars are one element. A gradient clipped to text, with a custom property for the fill percentage. Five separate icon elements per card, times thirty cards, is a lot of DOM for something decorative — and this version handles half stars for free by setting --pct: 90%.
The rating has a real label. The star glyphs are aria-hidden because "black star black star black star" is not useful. The aria-label on the container says what the rating actually is.
flex-direction: column with flex: 1 on the body is what keeps the buttons aligned across a row when product names wrap to different numbers of lines. Without it, a two-line title pushes its button down and the row looks broken.
The disabled state is a real disabled attribute, not a class. It removes the button from the tab order and announces itself, which a greyed-out but clickable button does not.
Accessibility notes
The image alt text describes the product, not the file. "Oatmeal linen shirt, folded" is useful; "IMG_4471" and "product image" are not.
Price is inside a normal paragraph so it is read in sequence. The struck-through original uses <s>, which most screen readers announce as deleted content — if the distinction matters commercially, add visually hidden text saying "was £72, now £58".
Do not rely on the badge colour alone to signal stock status. "Out of stock" is written out, and the button text changes as well.
Making it yours
Change minmax(230px, 1fr) to set how narrow a card may get before the grid drops a column. No media queries needed — the grid reflows on its own.
The hover lift animates transform rather than box-shadow, because transform runs on the compositor and shadow forces a repaint. The shadow generator has more on this.
Related templates
Article card
A blog post card with cover image, category, date and reading time.
HTMLCSSPricing table
Three tiers with a highlighted plan and a monthly-to-yearly toggle.
HTMLCSSJavaScriptProfile card
A team or author card with avatar, role, bio and social links.
HTMLCSSHoly grail layout
Header, footer, two sidebars and a main column — in nine lines of grid.
HTMLCSSCheck your version
Once you have edited this, the HTML validator will catch any tag you left unclosed, and the formatter will tidy the indentation. Both run in your browser.