spinbutton
A form of range that expects the user to select from among discrete choices. Spinbuttons are typically used for numeric inputs where users can increment or decrement the value using buttons or keyboard controls.
Overview
The spinbutton role identifies an input widget for selecting from a range of discrete values. Unlike sliders which use continuous values, spinbuttons allow precise numeric input through typing, increment/decrement buttons, or arrow key navigation.
Spinbuttons are commonly used for quantity selectors, time pickers, and other scenarios where users need to enter specific numeric values within a defined range.
Native <input type="number"> vs role="spinbutton"
The native <input type="number"> provides built-in spinbutton functionality with increment/decrement controls, keyboard support, and mobile numeric keyboards. Use role="spinbutton" only when you need custom styling or behavior that cannot be achieved with the native element.
Live Demo: Spinbutton Interactions
Quantity Selector
Time Picker
Font Size (with valuetext)
Try with keyboard: Focus an input, then use ↑ ↓ to change values, Home/End for min/max, or type a number directly.
Code Examples
Basic Spinbutton
<!-- Basic Spinbutton -->
<div class="spinbutton-container">
<label for="quantity" id="quantity-label">Quantity</label>
<div class="spinbutton-wrapper">
<input
type="text"
id="quantity"
role="spinbutton"
aria-labelledby="quantity-label"
aria-valuenow="1"
aria-valuemin="1"
aria-valuemax="99"
value="1"
/>
<div class="spinbutton-buttons">
<button aria-label="Increase quantity" tabindex="-1">▲</button>
<button aria-label="Decrease quantity" tabindex="-1">▼</button>
</div>
</div>
</div>
<!-- Note: Buttons have tabindex="-1" so only the input is in tab order -->Native HTML Number Input (Preferred)
<!-- Native HTML Number Input (Preferred) -->
<label for="quantity">Quantity</label>
<input
type="number"
id="quantity"
min="1"
max="99"
step="1"
value="1"
/>
<!-- Benefits of native number input:
- Built-in increment/decrement controls
- Automatic keyboard support (up/down arrows)
- Numeric keyboard on mobile devices
- Form validation built-in -->
<!-- Styling native number inputs -->
<style>
/* Hide default spinners in Chrome, Safari, Edge */
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Hide spinners in Firefox */
input[type="number"] {
-moz-appearance: textfield;
}
</style>Keyboard Navigation
<!-- Spinbutton with Full Keyboard Support -->
<div class="spinbutton-container">
<label id="font-label">Font Size (px)</label>
<input
type="text"
role="spinbutton"
aria-labelledby="font-label"
aria-valuenow="16"
aria-valuemin="8"
aria-valuemax="72"
aria-valuetext="16 pixels"
value="16"
id="font-size"
/>
</div>
<script>
const spinbutton = document.getElementById('font-size');
let value = 16;
const min = 8;
const max = 72;
const step = 1;
const largeStep = 4;
spinbutton.addEventListener('keydown', (e) => {
let newValue = value;
switch (e.key) {
case 'ArrowUp':
e.preventDefault();
newValue = Math.min(max, value + step);
break;
case 'ArrowDown':
e.preventDefault();
newValue = Math.max(min, value - step);
break;
case 'PageUp':
e.preventDefault();
newValue = Math.min(max, value + largeStep);
break;
case 'PageDown':
e.preventDefault();
newValue = Math.max(min, value - largeStep);
break;
case 'Home':
e.preventDefault();
newValue = min;
break;
case 'End':
e.preventDefault();
newValue = max;
break;
default:
return; // Allow other keys (like Tab)
}
if (newValue !== value) {
value = newValue;
updateSpinbutton();
}
});
// Handle manual text input
spinbutton.addEventListener('change', (e) => {
const inputValue = parseInt(e.target.value, 10);
if (!isNaN(inputValue)) {
value = Math.max(min, Math.min(max, inputValue));
updateSpinbutton();
}
});
function updateSpinbutton() {
spinbutton.value = value;
spinbutton.setAttribute('aria-valuenow', value);
spinbutton.setAttribute('aria-valuetext', value + ' pixels');
}
</script>Time Picker Pattern
<!-- Time Picker Spinbuttons -->
<div class="time-picker" role="group" aria-label="Set time">
<div class="time-field">
<label id="hours-label">Hours</label>
<input
type="text"
role="spinbutton"
aria-labelledby="hours-label"
aria-valuenow="12"
aria-valuemin="1"
aria-valuemax="12"
value="12"
class="time-input"
/>
</div>
<span class="time-separator">:</span>
<div class="time-field">
<label id="minutes-label">Minutes</label>
<input
type="text"
role="spinbutton"
aria-labelledby="minutes-label"
aria-valuenow="00"
aria-valuemin="0"
aria-valuemax="59"
value="00"
class="time-input"
/>
</div>
<div class="time-field">
<label id="period-label" class="sr-only">AM/PM</label>
<select id="period" aria-labelledby="period-label">
<option value="AM">AM</option>
<option value="PM">PM</option>
</select>
</div>
</div>
<!-- Note: Each component is a separate spinbutton
The group role associates them together -->React Component
// React Spinbutton Component
import { useState, useRef, useCallback, useEffect } from 'react';
interface SpinbuttonProps {
label: string;
min: number;
max: number;
step?: number;
value: number;
formatValue?: (value: number) => string;
onChange: (value: number) => void;
}
function Spinbutton({
label,
min,
max,
step = 1,
value,
formatValue,
onChange,
}: SpinbuttonProps) {
const [inputValue, setInputValue] = useState(String(value));
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setInputValue(String(value));
}, [value]);
const clamp = useCallback((val: number) => {
return Math.max(min, Math.min(max, val));
}, [min, max]);
const increment = useCallback(() => {
onChange(clamp(value + step));
}, [value, step, clamp, onChange]);
const decrement = useCallback(() => {
onChange(clamp(value - step));
}, [value, step, clamp, onChange]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
const largeStep = step * 10;
switch (e.key) {
case 'ArrowUp':
e.preventDefault();
increment();
break;
case 'ArrowDown':
e.preventDefault();
decrement();
break;
case 'PageUp':
e.preventDefault();
onChange(clamp(value + largeStep));
break;
case 'PageDown':
e.preventDefault();
onChange(clamp(value - largeStep));
break;
case 'Home':
e.preventDefault();
onChange(min);
break;
case 'End':
e.preventDefault();
onChange(max);
break;
}
}, [increment, decrement, value, min, max, step, clamp, onChange]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
const handleBlur = () => {
const parsed = parseInt(inputValue, 10);
if (!isNaN(parsed)) {
onChange(clamp(parsed));
} else {
setInputValue(String(value));
}
};
const valueText = formatValue ? formatValue(value) : String(value);
return (
<div className="spinbutton-container">
<label htmlFor="spinbutton-input">{label}</label>
<div className="spinbutton-wrapper">
<input
ref={inputRef}
type="text"
id="spinbutton-input"
role="spinbutton"
aria-valuenow={value}
aria-valuemin={min}
aria-valuemax={max}
aria-valuetext={valueText}
value={inputValue}
onChange={handleChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
inputMode="numeric"
/>
<div className="spinbutton-buttons">
<button
type="button"
onClick={increment}
disabled={value >= max}
aria-label="Increase"
tabIndex={-1}
>
▲
</button>
<button
type="button"
onClick={decrement}
disabled={value <= min}
aria-label="Decrease"
tabIndex={-1}
>
▼
</button>
</div>
</div>
</div>
);
}
// Usage
function App() {
const [quantity, setQuantity] = useState(1);
const [fontSize, setFontSize] = useState(16);
return (
<>
<Spinbutton
label="Quantity"
min={1}
max={99}
value={quantity}
onChange={setQuantity}
/>
<Spinbutton
label="Font Size"
min={8}
max={72}
step={2}
value={fontSize}
formatValue={(v) => `${v}px`}
onChange={setFontSize}
/>
</>
);
}Read-only Display
<!-- Read-only Spinbutton Display -->
<div class="spinbutton-display">
<span id="score-label">Current Score</span>
<div
role="spinbutton"
aria-labelledby="score-label"
aria-valuenow="850"
aria-valuemin="0"
aria-valuemax="1000"
aria-valuetext="850 points out of 1000"
aria-readonly="true"
tabindex="0"
>
850
</div>
</div>
<!-- Read-only spinbuttons display values in a range
but cannot be edited by the user -->Validation Pattern
<!-- Spinbutton with Validation -->
<div class="spinbutton-container">
<label for="age" id="age-label">
Age <span aria-hidden="true">*</span>
</label>
<input
type="text"
id="age"
role="spinbutton"
aria-labelledby="age-label"
aria-valuenow="25"
aria-valuemin="0"
aria-valuemax="120"
aria-required="true"
aria-invalid="false"
aria-describedby="age-hint age-error"
value="25"
/>
<span id="age-hint" class="hint">
Enter a value between 0 and 120
</span>
<span id="age-error" class="error" role="alert" hidden>
Please enter a valid age
</span>
</div>
<script>
const ageInput = document.getElementById('age');
const errorSpan = document.getElementById('age-error');
ageInput.addEventListener('blur', () => {
const value = parseInt(ageInput.value, 10);
const isValid = !isNaN(value) && value >= 0 && value <= 120;
ageInput.setAttribute('aria-invalid', !isValid);
errorSpan.hidden = isValid;
});
</script>Keyboard Support
Uses the step value (default: 1)
Uses the step value (default: 1)
Typically 10x the step value
Typically 10x the step value
Jumps to aria-valuemin
Jumps to aria-valuemax
User can type values directly
Best Practices
Use native <input type="number"> when possible for built-in accessibility
Always provide aria-valuenow, aria-valuemin, and aria-valuemax
Allow direct text input in addition to increment/decrement
Use aria-valuetext for non-numeric displays (e.g., "16px")
Make increment/decrement buttons non-focusable (tabindex="-1")
Validate and clamp input values on blur
Don't use spinbuttons for selecting from a list of named options
Don't forget to implement all keyboard controls
Don't allow values outside the min/max range
Don't make it impossible to type values directly
Supported ARIA Attributes
aria-valuenowRequiredCurrent numeric value
aria-valueminRequiredMinimum allowed value
aria-valuemaxRequiredMaximum allowed value
aria-valuetextHuman-readable alternative (e.g., "16 pixels")
aria-labelAccessible name for the spinbutton
aria-labelledbyReferences element(s) that label the spinbutton
aria-requiredIndicates if a value is required
aria-invalidIndicates validation state
aria-readonlyIndicates if the spinbutton is read-only
aria-disabledIndicates if the spinbutton is disabled
Common Use Cases
Accessibility Notes
Screen Reader Behavior
Screen readers announce spinbuttons as "[label], spinbutton, [value]". When the value changes via keyboard, the new value is announced. Use aria-valuetext to provide context like "16 pixels" instead of just "16".
Direct Text Entry
Unlike sliders, spinbuttons should allow users to type values directly. This is especially important for accessibility - some users find it easier to type a precise value than to use increment/decrement controls.
Focus Management
Only the text input/display should be in the tab order. Increment/decrement buttons should have tabindex="-1" since arrow keys already provide this functionality when focused.

