Components · · 2 min read
E-commerce component: product card, discount badge & star rating
The complete code for an online-store product card: a two-layer star rating with no images, aspect-ratio that prevents layout shift, line-clamp, and a sold-out state — editor + live preview side by side.
<div class="grid">
<article class="card">
<div class="card__media">
<span class="card__badge">-20%</span>
</div>
<div class="card__body">
<p class="card__kicker">Arabika</p>
<h3 class="card__name">Kopi Gayo Premium 250g</h3>
<!-- Rating: 1 elemen isi di atas 1 elemen dasar, lebar = persen -->
<span class="rating" role="img" aria-label="4,2 dari 5">
<span class="rating__base">★★★★★</span>
<span class="rating__fill" style="width: 84%">★★★★★</span>
</span>
<p class="card__price">
<strong>Rp 85.000</strong>
<s>Rp 106.000</s>
</p>
<button class="btn" type="button">+ Keranjang</button>
</div>
</article>
<article class="card card--out">
<div class="card__media">
<span class="card__badge card__badge--out">Habis</span>
</div>
<div class="card__body">
<p class="card__kicker">Robusta</p>
<h3 class="card__name">Robusta Temanggung 500g</h3>
<span class="rating" role="img" aria-label="5 dari 5">
<span class="rating__base">★★★★★</span>
<span class="rating__fill" style="width: 100%">★★★★★</span>
</span>
<p class="card__price"><strong>Rp 62.000</strong></p>
<button class="btn" type="button" disabled>Stok habis</button>
</div>
</article>
</div>.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 14px;
}
.card {
border: 1px solid #e4e4e7;
background: #fff;
display: flex;
flex-direction: column;
}
/* Rasio terkunci: grid tidak "loncat" saat gambar termuat */
.card__media {
position: relative;
aspect-ratio: 1 / 1;
background: #fafafa;
}
.card__badge {
position: absolute;
top: 10px;
left: 10px;
background: #0d9488;
color: #fff;
font-size: 11px;
font-weight: 600;
padding: 3px 8px;
}
.card__badge--out { background: #71717a; }
.card__body { padding: 14px; }
.card__kicker {
margin: 0;
font-size: 10px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #71717a;
}
.card__name {
margin: 6px 0 8px;
font-size: 14px;
line-height: 1.35;
/* Nama panjang tidak merusak tinggi kartu */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Rating bintang tanpa gambar & tanpa JavaScript */
.rating {
position: relative;
display: inline-block;
font-size: 13px;
line-height: 1;
color: #d4d4d8;
}
.rating__fill {
position: absolute;
inset: 0;
overflow: hidden;
white-space: nowrap;
color: #f59e0b;
}
.card__price {
margin: 10px 0 0;
display: flex;
align-items: baseline;
gap: 8px;
}
.card__price strong { font-size: 17px; }
.card__price s { font-size: 12px; color: #a1a1aa; }
.btn {
margin-top: 12px;
width: 100%;
border: 0;
background: #09090b;
color: #fff;
font-size: 12px;
padding: 9px 12px;
cursor: pointer;
}
.btn:hover { background: #0f766e; }
.btn:disabled {
background: #f4f4f5;
color: #a1a1aa;
cursor: not-allowed;
}Preview
The product card is the most-viewed component in an online store — it appears dozens of times on every catalog page. The editor above holds its complete code, and the right panel runs that same code: what you read is exactly what you see.
Star ratings without images and without JavaScript
The trick I use on every project: two identical layers of star text, one on top of the other. The bottom layer is grey (★★★★★), the top layer is gold and clipped with a percentage width:
<span class="rating" role="img" aria-label="4.2 out of 5">
<span class="rating__base">★★★★★</span>
<span class="rating__fill" style="width: 84%">★★★★★</span>
</span>
4.2 out of 5 = 84%. Half-star ratings work automatically — no sprites, no SVG, no JavaScript math. role="img" + aria-label makes screen readers announce one value instead of ten star characters.
Three details that keep the grid tidy
aspect-ratio: 1 / 1on the media area. Without it, cards are short until images load and then “jump” — layout shift that wrecks Core Web Vitals and feels cheap.-webkit-line-clamp: 2on the product name. Long names cap at two lines, so every card in a grid row shares the same height.grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)). The column count adapts on its own, without a single media query.
Out-of-stock state: disable it, don’t hide it
Sold-out products stay visible (for SEO and for shoppers willing to wait for a restock), but the button is disabled and the badge turns grey. What matters: that state is communicated through the disabled attribute, not just color — a disabled button is automatically skipped by keyboard navigation and announced by screen readers.
Price: one format, one helper
Note the price structure — the active price is <strong>, the struck-through price is <s>. The <s> element carries the semantic meaning “no longer applicable”, exactly right for a pre-discount price. Formatting always goes through the same Intl.NumberFormat helper (see the Catalog + cart build note); never format it by hand per location.
Lessons
- Visual effects that look like they “need a library” often need two elements and one
overflow: hidden. - Lock ratios and text heights up front — layout shift is the bug most often dismissed as normal while being the most felt.
- Every state (sold out, discounted, rated) needs a non-visual marker, not just a color.
Want something like this built for your business?