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.
<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>.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; }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-labelon 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?