Forms & Input

Spinbutton

Available

A numeric input that increments and decrements via Up/Down keys or buttons, with range announcements.

What is a spin button?

A spin button is a UI control that lets users increment or decrement a value one step at a time using + / − (or ↑ / ↓), selecting a number within a given range. It is commonly used for specifying quantities, counts, or floor numbers.

It is similar to a slider, but a spin button excels at selecting precise, discrete values. The information it needs to convey is the same: the current value, the range (minimum and maximum), and keyboard interaction.

Why does accessibility matter?

The easiest way to meet all of these requirements is to use the native <input type="number">. The browser provides the increment/decrement buttons, keyboard interaction, and screen reader announcements automatically.

Live demo (recommended implementation)

Below is a native <input type="number">.Without using a mouse, focus the input and try changing the value with ↑ and ↓.

Accessible spin button (native number input)

Try it: Tab to the input → ↑ ↓ to increment/decrement by 1, or type a number directly. Range: 1–10.

Tip

When a screen reader focuses the input, it announces something like "Quantity, spin button, 1," conveying the role and current value. As you change the value with ↑↓, the new value is announced. All of this is provided automatically by the native element.

Keyboard interaction

KeyActionPriority
↑Increase the value by one stepRequired
↓Decrease the value by one stepRequired
Home / EndSet to minimum / maximum valueRecommended
Number keysEnter a value directlyOptional

Note

Incrementing/decrementing with ↑ ↓ and direct input are automatic with <input type="number">. For custom implementations (role="spinbutton"), you need to handle ↑↓ yourself via a keydown listener.

Required WAI-ARIA roles and properties

TargetAttribute / RoleMeaning
Native number input<label for> associationGive the input a label. Role, value, range, and increment/decrement are handled automatically.
Custom spinbutton elementrole="spinbutton"Convey to assistive technologies that this is a spinbutton.
Custom spinbutton elementtabindex="0"Make the element keyboard-focusable so it can receive Up/Down arrow key events.
Custom spinbutton elementaria-valuemin / aria-valuemaxDefines the selectable range (minimum and maximum).
Custom spinbutton elementaria-valuenowCurrent value. Must be updated on every increment or decrement.
Custom spinbutton elementaria-label / aria-labelledbyA label describing what value the spinbutton controls.
Increment/decrement buttons<button> + aria-label="Increase/Decrease"Use real button elements for + and -; add a label if using icon-only buttons.

Implementation: recommended pattern (Good)

Good / Recommended

Start with <input type="number">. Just associating a label gives you the role, value, range, and keyboard interaction for free.

Markup:

<label for="qty">Quantity</label>
<input
  type="number"
  id="qty"
  name="qty"
  min="1"
  max="10"
  step="1"
  value="1" />

Only when design requirements absolutely prevent using the native element should you build a custom one with role="spinbutton". Make the + and − real <button> elements, and handle ↑↓ on the spinbutton itself.

<!-- Only build a custom one when native doesn't meet design requirements -->
<span id="ppl-label">Number of adults</span>
<div class="spin">
  <button type="button" data-dec aria-label="Decrease">−</button>
  <div role="spinbutton"
       tabindex="0"
       aria-labelledby="ppl-label"
       aria-valuemin="1"
       aria-valuemax="9"
       aria-valuenow="2"
       id="ppl-spin">2</div>
  <button type="button" data-inc aria-label="Increase">+</button>
</div>
const spin = document.getElementById('ppl-spin');
const MIN = 1, MAX = 9, STEP = 1;

function setValue(next) {
  const v = Math.min(MAX, Math.max(MIN, next));
  spin.setAttribute('aria-valuenow', String(v));
  spin.textContent = String(v); // Keep the visual display in sync
}

document.querySelector('[data-inc]').addEventListener('click', () =>
  setValue(Number(spin.getAttribute('aria-valuenow')) + STEP)
);
document.querySelector('[data-dec]').addEventListener('click', () =>
  setValue(Number(spin.getAttribute('aria-valuenow')) - STEP)
);

// Allow incrementing/decrementing with ↑↓ when the spinbutton itself has focus
spin.addEventListener('keydown', (e) => {
  const now = Number(spin.getAttribute('aria-valuenow'));
  if (e.key === 'ArrowUp')   { e.preventDefault(); setValue(now + STEP); }
  if (e.key === 'ArrowDown') { e.preventDefault(); setValue(now - STEP); }
  if (e.key === 'Home') { e.preventDefault(); setValue(MIN); }
  if (e.key === 'End')  { e.preventDefault(); setValue(MAX); }
});

Anti-pattern (Bad)

Below is a spin button built with a text input and <div>-based + and − buttons.You can click them with a mouse, but you cannot increment/decrement with the keyboard, and neither the value nor the range is announced.

Broken spin button built with text input + div buttons
Quantity
−
+

Try it: Pressing Tab does not focus the + or − buttons. A screen reader cannot identify this as a 'spin button' or announce the current value.

<!-- ❌ Anti-pattern: text input + div-based + and − buttons -->
<span>Quantity</span>
<div class="spin">
  <div class="btn" onclick="dec()">−</div>   <!-- div: not focusable -->
  <input type="text" id="qty" value="1">      <!-- No label, not a number input -->
  <div class="btn" onclick="inc()">+</div>
</div>
<!--
  No role="spinbutton" or aria-valuenow/min/max.
  The + and − are divs, so they can't be reached by keyboard,
  and the value and range are not announced to assistive technologies.
-->

Bad / Avoid

Problems with this implementation:

  • Cannot increment/decrement via keyboard — The + and − are div elements that cannot receive focus or be activated.
  • Role is not communicated — Without role="spinbutton", it is not recognized as a spin button.
  • Value and range are not communicated — Missing aria-valuenow / valuemin / valuemax.
  • No accessible name — The label is not associated, so the user cannot tell what this number represents.

Implementation checklist

Field Notes — Where practice diverges from the APG

NumberField does not use role="spinbutton"

The APG says
Custom numeric inputs should have role="spinbutton" with aria-valuenow / aria-valuemin / aria-valuemax, and support incrementing and decrementing via the Up/Down arrow keys.
What practice does
The NumberField in React Aria and Base UI does not use the spinbutton role. Instead it renders a type="text" input (inputMode="numeric") with an aria-roledescription such as "Number field". Arrow-key stepping still works, but assistive technologies see a described text input.
Why
React Aria's docs say the component "follows the spinbutton ARIA pattern" — and in the same breath that it "works around bugs in VoiceOver with the spinbutton role". Their i18n blog post further explains choosing input type="text" + inputMode + a custom role description over type="number" to support currencies, percentages, and units. It is a measured workaround: using the APG role at face value breaks on real screen readers. Base UI adopts the same construction.

Adopted by:React Aria、Base UI

Sources:React Aria — useNumberField(Accessibility features)(opens in a new tab)、React Aria Blog — How we internationalized our number field(opens in a new tab)、Base UI — Number Field(opens in a new tab)


Source (English):Spinbutton Pattern — W3C APG(opens in a new tab)