Docs menu

Components · · 3 min read

E-commerce component: a quantity stepper with stock limits

A quantity stepper done right: bounds enforced with disabled (not alerts), one render function as the source of truth for subtotal & remaining stock, plus aria-label and aria-live — try it in the preview.

HTMLCSSVanilla JSA11y
Live exampleRendered by the actual component — not an image.
1
2
3
4
5
6
7
8
9
10
11
<div class="row">
  <div class="stepper" data-stepper data-max="8">
    <button class="stepper__btn" type="button" data-dec aria-label="Kurangi">−</button>
    <input class="stepper__val" type="text" inputmode="numeric"
           value="1" readonly aria-live="polite" />
    <button class="stepper__btn" type="button" data-inc aria-label="Tambah">+</button>
  </div>
  <p class="stok">Sisa <strong data-sisa>8</strong> pcs</p>
</div>

<p class="total">Subtotal: <strong data-total>Rp 85.000</strong></p>
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
.row {
  display: flex;
  align-items: center;
  gap: 14px;
  flex-wrap: wrap;
}

.stepper {
  display: inline-flex;
  border: 1px solid #e4e4e7;
}

.stepper__btn {
  width: 38px;
  height: 38px;
  border: 0;
  background: #fff;
  font-size: 16px;
  cursor: pointer;
  color: #09090b;
}
.stepper__btn:hover:not(:disabled) { background: #fafafa; }
.stepper__btn:disabled { color: #d4d4d8; cursor: not-allowed; }

.stepper__val {
  width: 46px;
  height: 38px;
  border: 0;
  border-left: 1px solid #e4e4e7;
  border-right: 1px solid #e4e4e7;
  text-align: center;
  font-size: 14px;
  background: #fff;
}

.stok { margin: 0; font-size: 13px; color: #71717a; }
.total { margin: 16px 0 0; font-size: 14px; }
.total strong { font-size: 18px; }
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
const HARGA = 85000;
const rupiah = (n) => new Intl.NumberFormat("id-ID", {
  style: "currency", currency: "IDR", maximumFractionDigits: 0,
}).format(n);

document.querySelectorAll("[data-stepper]").forEach((root) => {
  const max = Number(root.dataset.max);
  const input = root.querySelector(".stepper__val");
  const dec = root.querySelector("[data-dec]");
  const inc = root.querySelector("[data-inc]");
  let qty = 1;

  function render() {
    input.value = String(qty);
    // Batas dijaga lewat disabled, bukan lewat alert
    dec.disabled = qty <= 1;
    inc.disabled = qty >= max;
    document.querySelector("[data-sisa]").textContent = String(max - qty);
    document.querySelector("[data-total]").textContent = rupiah(HARGA * qty);
  }

  dec.addEventListener("click", () => { if (qty > 1) { qty--; render(); } });
  inc.addEventListener("click", () => { if (qty < max) { qty++; render(); } });
  render();
});

Preview

A quantity stepper (− 1 +) looks trivial, but on a product page it touches three things at once: stock limits, a subtotal that must follow along, and accessibility. Try the buttons in the preview panel — the upper and lower bounds genuinely work.

Bounds enforced with disabled, not with warnings

A mistake I see often: the user presses “−” until the quantity hits 0 or goes negative, then an alert() appears. The correct approach disables the button exactly at the boundary:

dec.disabled = qty <= 1;
inc.disabled = qty >= max;

The result: no action needs undoing, invalid buttons are skipped during keyboard navigation, and screen readers announce them as dimmed/unavailable. A boundary that can’t be crossed always beats an error message after crossing it.

One render() function as the source of truth

Everything that depends on qty — the input value, both button states, remaining stock, and the subtotal — updates in one place:

function render() {
  input.value = String(qty);
  dec.disabled = qty <= 1;
  inc.disabled = qty >= max;
  document.querySelector("[data-sisa]").textContent = String(max - qty);
  document.querySelector("[data-total]").textContent = rupiah(HARGA * qty);
}

The event handlers only change qty and call render(). This pattern prevents the classic “the subtotal doesn’t update when you click fast” bug — because no value is ever updated in two different places.

Accessibility: the three attributes that decide it

  • aria-label on the buttons. Screen readers read and + as math symbols, not “decrease”/“increase”.
  • aria-live="polite" on the input. When the number changes from a click, that change is announced without interrupting other speech.
  • inputmode="numeric". On phones, the numeric keyboard appears immediately if the input is editable.

The input is deliberately readonly in this example: its value changes only through the buttons, so there’s no odd state like "abc" or 007. If you want typing, validation must be added on change — and still clamped to the 1..max range.

Wiring it to the cart

In a real project, render() needs one extra line to write qty into cart state (localStorage or POST /cart — see the Catalog + cart build note). Because every change already flows through one function, there’s exactly one integration point.

Lessons

  • Prevent invalid actions; don’t correct them afterwards.
  • One render function for all derived state = zero synchronization bugs.
  • Even small components need labels — symbols aren’t text to a screen reader.

Want something like this built for your business?