Loading Developer Playground

Loading ...

Skip to main content
ARIA ROLEWidget Roles

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.

Required Attributes
aria-valuenow, min, max
Native HTML Equivalent
<input type="number">
Keyboard Controls
↑↓, Page Up/Down, Home/End

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

(1-99)

Time Picker

:

Font Size (with valuetext)

16
Aa

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

Increments value by one step

Uses the step value (default: 1)

Decrements value by one step

Uses the step value (default: 1)

Page Up
Increments by a larger amount

Typically 10x the step value

Page Down
Decrements by a larger amount

Typically 10x the step value

Home
Sets value to minimum

Jumps to aria-valuemin

End
Sets value to maximum

Jumps to aria-valuemax

0-9
Allows direct numeric input

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-valuenowRequired

Current numeric value

aria-valueminRequired

Minimum allowed value

aria-valuemaxRequired

Maximum allowed value

aria-valuetext

Human-readable alternative (e.g., "16 pixels")

aria-label

Accessible name for the spinbutton

aria-labelledby

References element(s) that label the spinbutton

aria-required

Indicates if a value is required

aria-invalid

Indicates validation state

aria-readonly

Indicates if the spinbutton is read-only

aria-disabled

Indicates if the spinbutton is disabled

Common Use Cases

Shopping cart quantity selectors
Time/date pickers
Font size adjusters
Numeric input in forms
Page number navigation
Zoom level controls
Pagination controls
Counter/stepper inputs

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.

Related Roles & Attributes

Specifications & Resources