Docs menu

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.

HTMLCSSE-commerceA11y
Live exampleRendered by the actual component — not an image.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
.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 / 1 on 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: 2 on 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?