# Quantity steppers with unnamed buttons and no typing

Source: https://easeweb.dev/learn/quantity-steppers
Topics: Custom inputs > Quantity steppers have a name and can be typed into (WCAG 4.1.2, 2.1.1)
The fix: Name each button by what it does, with visually hidden text, and hide its icon. Then either replace the printed number with a labelled number field that the buttons step, or use the browser's number field on its own. Never cancel every key in a number field: block only the few characters a quantity never has.
Test with: keyboard, screen reader, touch
References: https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html https://www.w3.org/WAI/WCAG22/Techniques/failures/F68 https://www.w3.org/WAI/ARIA/apg/patterns/spinbutton/ https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/number https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/stepUp

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A shop's page for an enamel mug at $18 has a quantity stepper above Add to bag: a round minus button, the number 1, and a round plus button. With a mouse it works: press plus three times and the number reads 4. Tab to it with a screen reader running, and you hear "button", then "button" again. The two are told apart only by their icons, a minus and a plus, and neither has a word in it. The number between them is not a Tab stop, so after a press nothing says what it is now; to find out, a screen reader user leaves the buttons and reads the line. Nobody can type into it either: ten mugs for a café take nine presses of plus.

The same shop's other build is a plain number field labelled "Quantity", with the browser's small up and down arrows at its end. A developer has cancelled every key in it except Tab, to stop people typing letters or "-3". Tab reaches it, and a screen reader reads "Quantity, spin button, 1", but typing 4 does nothing, and neither do the up and down arrow keys, which the browser would otherwise use to step the number. Only a mouse on the small arrows changes it.

## Why it fails

Success Criterion 4.1.2 asks that every control has a name that assistive technology can read. A button takes its name from its content, and here the content is an SVG with no title and no text, so the name is empty and a screen reader has only the role to say. That is failure F68: a control with no name in code. The minus and plus are drawn, not written, and the browser can't read a shape. The number is a `span`, plain text with no role, so it is read only when someone reads the page line by line, never with the buttons, and nothing in the code ties it to them.

Success Criterion 2.1.1 asks that everything a mouse can do can be done from a keyboard. In the second build, the field would manage it by itself: browsers give a number field typing and the up and down arrows. But the browser does a key's work only after the page's `keydown` handlers have run, and `preventDefault()` in one of them cancels that work, so every key, the arrows included, does nothing. The spin arrows at the end of the field are drawn by the browser for a pointer and never take focus, so a mouse is the only way left to set the number. Nothing in HTML can refuse this: blocking the keys is one line of valid script.

## Who is affected

A quantity stepper sits on the way to the basket, so one that can't be read or set stops the purchase, or sends the wrong number of items. Shops repeat the same stepper on every row of the basket, so each row adds two more buttons called nothing.

## What automation and AI miss

axe reports the first build's buttons as having no name (`button-name`), but it can't tell that the number between them can't be typed: a span with a number in it is valid markup. A scanner sees nothing wrong with the second build at all. The markup is a labelled number field, which is correct, and only pressing the keys shows that they are blocked. EaseWeb's audit presses the arrows and types a digit in each number field that has no stepper buttons beside it, and reports a field that none of them changes. Asked to fix the scanner's finding, code assistants may name each button after its picture ("+", "minus") rather than what it does, or add the names and stop there, which leaves the number untypeable. Asked to keep letters out of a number field, they may suggest cancelling `keydown`, which takes the arrows and the digits with it.

## Before: unnamed icon buttons around a printed number

```html
<form class="buy" action="/basket" method="post">
  <span class="qty-label">Quantity</span>
  <div class="stepper">
    <button type="button" class="step" data-step="-1">
      <svg class="icon" viewBox="0 0 16 16">…</svg>
    </button>
    <span class="qty">1</span>
    <button type="button" class="step" data-step="1">
      <svg class="icon" viewBox="0 0 16 16">…</svg>
    </button>
  </div>
  <input type="hidden" name="quantity" value="1">
  <button type="submit">Add to bag</button>
</form>
```

```js
const form = document.querySelector('.buy');
for (const button of form.querySelectorAll('.step')) {
  button.addEventListener('click', () => {
    const field = form.elements.quantity;
    const value = Math.min(10, Math.max(1, Number(field.value) + Number(button.dataset.step)));
    field.value = value;
    form.querySelector('.qty').textContent = value;
  });
}
```

Each button holds only an SVG with no title, so its name is empty and a screen reader says just "button". The quantity is a `span` and a hidden input: it is not read with the buttons, and nothing can be typed into it.

## Before: a number field that cancels its keys

```html
<form class="buy" action="/basket" method="post">
  <label for="qty">Quantity</label>
  <input id="qty" name="quantity" type="number" min="1" max="10" value="1">
  <button type="submit">Add to bag</button>
</form>
```

```js
const qty = document.querySelector('.buy').elements.quantity;
// Only the spin arrows may change the number, so nothing odd can be typed.
qty.addEventListener('keydown', (event) => {
  if (event.key !== 'Tab') event.preventDefault();
});
```

The field is labelled and read correctly, but the handler cancels every key but Tab before the browser can act on it. Digits never go in, and the up and down arrows never step it, so the number can be changed only with a mouse on the spin arrows.

## After: named buttons around a number field

```html
<form class="buy" action="/basket" method="post">
  <label for="qty" class="qty-label">Quantity</label>
  <div class="stepper">
    <button type="button" class="step" data-step="-1">
      <svg class="icon" viewBox="0 0 16 16" aria-hidden="true">…</svg>
      <span class="visually-hidden">Decrease quantity</span>
    </button>
    <input id="qty" name="quantity" type="number" min="1" max="10" value="1">
    <button type="button" class="step" data-step="1">
      <svg class="icon" viewBox="0 0 16 16" aria-hidden="true">…</svg>
      <span class="visually-hidden">Increase quantity</span>
    </button>
  </div>
  <button type="submit">Add to bag</button>
</form>
```

```js
const form = document.querySelector('.buy');
const qty = form.elements.quantity;
for (const button of form.querySelectorAll('.step')) {
  button.addEventListener('click', () => {
    // stepUp() and stepDown() keep the number between min and max.
    if (button.dataset.step > 0) qty.stepUp();
    else qty.stepDown();
  });
}
```

Each button now says what it does, "Decrease quantity, button", and its icon is hidden so it adds nothing to the name. The number is a real field: it is read as "Quantity, spin button, 1", people can type 10 straight in, and the up and down arrows step it. The field is also what the form sends, so the hidden input goes, and `stepUp()` and `stepDown()` stop at `min` and `max`, so the buttons can't take the number under 1 or past 10. The design keeps its round buttons. If it shows only those, the browser's own spin arrows can be hidden with CSS; the arrow keys keep working, because they belong to the field, not to the arrows.

## After: the browser's number field on its own

```html
<form class="buy" action="/basket" method="post">
  <label for="qty">Quantity</label>
  <input id="qty" name="quantity" type="number" min="1" max="10" value="1">
  <button type="submit">Add to bag</button>
</form>
```

```js
const qty = document.querySelector('.buy').elements.quantity;
// Keep out only what a quantity never has; digits, arrows and editing keys still work.
qty.addEventListener('keydown', (event) => {
  if (['e', 'E', '+', '-', '.', ','].includes(event.key)) event.preventDefault();
});
```

The browser's field does the whole job: typing, the up and down arrows, a role and value read as "Quantity, spin button, 1", and `min` and `max`. The script now cancels only six characters that a number field may otherwise accept and a quantity never has, the exponent "e", signs and decimal points, so the developer's aim of keeping odd entries out still holds. It is the smallest fix for a field whose keys were blocked, and it needs no buttons at all. The look changes from the round buttons, though: what remains is the small arrows that desktop browsers draw at the end of the field, which a phone's browser may not draw at all; there, a tap on the field opens a keyboard for entering numbers.

## After: names on the buttons only

```html
<form class="buy" action="/basket" method="post">
  <span class="qty-label">Quantity</span>
  <div class="stepper">
    <button type="button" class="step" data-step="-1">
      <svg class="icon" viewBox="0 0 16 16" aria-hidden="true">…</svg>
      <span class="visually-hidden">Decrease quantity</span>
    </button>
    <span class="qty">1</span>
    <button type="button" class="step" data-step="1">
      <svg class="icon" viewBox="0 0 16 16" aria-hidden="true">…</svg>
      <span class="visually-hidden">Increase quantity</span>
    </button>
  </div>
  <input type="hidden" name="quantity" value="1">
  <button type="submit">Add to bag</button>
</form>
```

```js
const form = document.querySelector('.buy');
for (const button of form.querySelectorAll('.step')) {
  button.addEventListener('click', () => {
    const field = form.elements.quantity;
    const value = Math.min(10, Math.max(1, Number(field.value) + Number(button.dataset.step)));
    field.value = value;
    form.querySelector('.qty').textContent = value;
  });
}
```

This is the smallest change to the first build, and the one a scanner's finding asks for: each button is read as "Decrease quantity, button" or "Increase quantity, button", which passes 4.1.2 for both. It is not enough on its own. The quantity is still a printed number, so nobody can type it, and a press leaves focus on the button with nothing to say what the number has become. Use it as a first step, when the template can't change yet, and replace the span with a field when it can.

## Which option to use

All three name the buttons, and the first two also make the quantity a field that can be typed into and stepped with the arrow keys. Named buttons around a number field keep the design as drawn and add typing; choose them when the large round buttons matter, as they usually do on a phone, where they are easier to hit than anything else in the stepper. The browser's number field on its own is the least code, and the direct fix for a field whose keys were blocked; choose it where a plain field fits the design, such as a basket table on a desktop page, or a trade order form where people order dozens at a time. Names on the buttons only is the quickest change and clears the scanner's finding, but leaves the number untypeable, so treat it as a step towards the first fix, not as the fix.

## Implementation decisions

A few choices decide how the stepper behaves once it is in a page:

- Names in a basket: when every row has a stepper, a list of buttons reads "Decrease quantity" and "Increase quantity" over and over. Put the product in the name ("Increase quantity, Enamel mug") and in the field's label, as visually hidden text after the visible word.
- The lowest and highest quantity: leave the buttons enabled at 1 and 10, since `stepDown()` and `stepUp()` simply do nothing there. Disabling a button while it has focus drops focus to the page; see [disabled buttons that can't be found or say why](/learn/disabled-controls).
- What a press says: focus stays on the button, so a screen reader doesn't read the field's new number, though the field sits right beside the button in the Tab order. If testing shows people miss it, a short polite status such as "Quantity 4" fills the gap. Announcing what Add to bag did to the basket is another topic, confirming actions such as add to cart out loud.
- Numbers out of range: a person can still type 30 into a field whose `max` is 10. When the form is sent, the browser refuses it and says why; if your page corrects the number itself instead, say so beside the field ("Only 10 left") rather than changing it silently.

If a script cancels Tab as well as the other keys, focus can't leave the field at all, which is a different failure; see [keyboard traps in editors and embedded widgets](/learn/keyboard-traps). Don't make the field `readonly` to keep letters out either: that takes away typing and the arrow keys just as surely as the blocked keys did, and in most browsers the spin arrows with them.

Stepper components in shop themes and design systems vary. Some name the buttons but draw the number as text; some use a field but leave the icons unnamed; some block keys to keep letters out. Check what the browser exposes for each part in its accessibility panel, not what the component's documentation promises.

## Verify the fix

1. Inspect each button in the browser's accessibility panel: its name says what it does, and the icon adds nothing to it.
2. Put the mouse aside. Tab to the quantity, type a new number, then press the up and down arrows: each changes it, and Tab still moves on to Add to bag.
3. Tab to each button and press it with Enter, then with Space: the number goes down or up by one, stops at the lowest and highest quantity, and focus stays on the button.
4. With a screen reader, Tab through the stepper: each button is read with its name and "button", and the field with its label, its role and its number.
5. On a phone, tap the field and check that a keyboard for entering numbers opens; then, with the screen reader on, swipe through the stepper and hear each button's name and the field's label and number.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the stepper component or the theme changes.

## Limits

This is a starting pattern, not a guarantee for every application. Screen readers name a number field's role in different words, and some read its number before its label. Steppers that set a guest count, a number of nights or a time follow the same pattern, but a stepper that steps through words, such as sizes, is a different control: a select or a group of radio buttons. If your control moves along a track, see [range sliders that only move by dragging](/learn/sliders); for icon buttons elsewhere on the page, such as a header's search and menu, see [icon-only buttons with no accessible name](/learn/icon-buttons). Test the full journey from product page to basket with your target assistive technology.

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
