Every store has one. A minus button, a number, a plus button. It looks like a ten-minute job, and that's exactly why it usually ships broken in some quiet way that nobody notices until a customer emails asking why they got one bag of cement instead of twelve.
Here's a checklist I'd go through before calling a quantity stepper done. Nothing here is framework-specific.
1. Start from a real input, not a styled span
The most common version I see in the wild is this:
<div class="qty"> <span onclick="dec()">-</span> <span id="count">1</span> <span onclick="inc()">+</span> </div> It works with a mouse. It does nothing with a keyboard, a screen reader announces "minus, one, plus" with no idea what any of it means, and you can't type 12 on your phone without tapping the plus button eleven times.
Start with an input and add buttons around it:
<label for="qty">Quantity</label> <div class="qty"> <button type="button" aria-label="Decrease quantity" aria-controls="qty">−</button> <input id="qty" name="quantity" type="text" inputmode="numeric" pattern="[0-9]*" value="1" autocomplete="off"> <button type="button" aria-label="Increase quantity" aria-controls="qty">+</button> </div> Two choices in there that people argue about:
-
type="text"withinputmode="numeric"instead oftype="number". Number inputs change value when someone scrolls the page with the cursor over them, they accepteand-, and their built-in spinner arrows are tiny. Text plusinputmodestill brings up the numeric keypad on phones without the surprises. If you prefertype="number", that's fine, just test the scroll-wheel thing. - A visible label. "Quantity" next to the box costs you a few pixels. Leaving it out means a screen reader user lands on an unlabeled edit field.
2. The buttons need names, and − isn't one
A button whose only content is a minus sign gets read as "minus" or sometimes "hyphen", depending on the character and the screen reader. aria-label="Decrease quantity" fixes that. If you have several steppers on one page (a cart, say), include the product name: "Decrease quantity, Cement 25 kg".
Also use a real <button type="button">. Without type="button", a button inside a form submits it. That's how "click plus, item gets added to the cart with quantity 1" bugs happen.
3. Announce the change
When someone presses plus, the value in the input changes, but focus stays on the button, so a screen reader may say nothing at all. The simplest fix is a polite live region:
<p class="visually-hidden" aria-live="polite" id="qty-status"></p> function setQty(next) { input.value = String(next); status.textContent = `Quantity ${next}`; } Keep the message short. Announcing "Quantity updated successfully to 3 units" on every click gets old fast.
4. Decide what happens at the edges
Write these down before you code, because each one has a "do nothing" default that's wrong:
- Minimum. Usually 1. When the value is at the minimum, either disable the minus button or let it remove the line (in a cart). Pick one and be consistent. If you disable it,
aria-disabled="true"keeps it focusable so the keyboard user isn't suddenly thrown somewhere else on the page. - Maximum. If there's a stock limit, tell people when they hit it, in text, next to the field. A plus button that silently stops working feels like a bug.
- Pack sizes. Some products only sell in multiples (a box of 100 screws, a pallet of blocks). Either step in that multiple or make the unit explicit: "Boxes" instead of "Quantity". Mixing them is how people order 100 boxes when they meant 100 screws.
- Empty field. Someone deletes the 1 to type 12 and then clicks away halfway. Don't snap back to 1 on every keystroke; validate on blur or on submit.
5. Validate typed input without fighting the user
Typed values are where most bugs live. A small, forgiving parser goes a long way:
function parseQty(raw, { min = 1, max = Infinity, step = 1 } = {}) { const n = Number.parseInt(String(raw).replace(/[^\d]/g, ''), 10); if (Number.isNaN(n)) return min; const clamped = Math.min(Math.max(n, min), max); return Math.ceil(clamped / step) * step; } It strips stray characters (people paste "12 pcs"), clamps to the allowed range, and rounds up to the pack size. If the number you end up with differs from what they typed, say so: "Sold in packs of 10, rounded up to 20." Quietly changing someone's order is worse than showing a message.
And whatever the browser does, validate again on the server. The client-side stepper is a convenience, not a guarantee.
6. Touch targets and layout
The minus and plus buttons are often 24 px squares squeezed next to the input. On a phone that's a lot of mis-taps. Give them a comfortable hit area (WCAG 2.2 sets a 24×24 CSS px minimum for target size, and bigger is kinder), and leave a little space between them and the input so a thumb doesn't land on the wrong one.
If your site has an RTL version, check that the buttons don't flip in a confusing way. In Hebrew or Arabic layouts, people still expect plus to mean "more", but the visual order of minus/plus can swap with dir="rtl". Decide which order you want and set it on purpose instead of letting the flexbox decide.
7. Don't trigger a network request on every click
If changing the quantity in a cart updates the server, someone clicking plus five times shouldn't fire five requests that can arrive out of order. Debounce the update, or send the final value when they stop clicking, and show the total as pending until it comes back. Out-of-order responses are a classic source of "my cart says 3 but I clicked to 5".
A two-minute test
Before shipping, try this:
- Tab to the stepper. Can you reach the field and both buttons?
- Type 12 on a phone. Did you get a numeric keypad?
- Press plus with a screen reader running. Did it say anything useful?
- Paste "abc". Then paste "15 units". What happens?
- Go below the minimum and above the stock limit.
If all five behave, you've got a better stepper than most stores.
I spent more time on this than I expected while working on RAMO, a hardware store where quantities are rarely 1. Things like building materials are bought by the bag or the box, and that's where every one of these edge cases shows up.