NumberInput

A simple counter component with increment and decrement buttons

Installation

pnpm add @wandercom/design-system-web

Usage

import { NumberInput } from '@wandercom/design-system-web/ui/number-input';

export function Example() {
  const [value, setValue] = useState<number | null>(5);

  return (
    <NumberInput
      value={value}
      min={0}
      max={10}
      step={1}
      onChange={setValue}
    />
  );
}

Examples

Default

Basic number input with increment and decrement buttons.

Loading example...

With placeholder

Number input displaying a placeholder when value is null.

Loading example...
const [value, setValue] = useState<number | null>(null);

<NumberInput
  value={value}
  min={0}
  max={100}
  step={5}
  placeholder="Any"
  onChange={setValue}
/>

With currency

Number input with currency symbol prefix.

Loading example...
const [value, setValue] = useState<number | null>(50);

<NumberInput
  value={value}
  min={0}
  max={500}
  step={25}
  currency="USD"
  onChange={setValue}
/>

Custom placeholder

Number input with a custom placeholder text.

Loading example...
const [value, setValue] = useState<number | null>(null);

<NumberInput
  value={value}
  min={1}
  max={10}
  step={1}
  placeholder="None"
  onChange={setValue}
/>

International formatting

Number input demonstrating various currency and locale combinations with proper formatting:

  • United States (en-US): $1,500 (comma separator, symbol before)
  • Germany (de-DE): 1.500 (period separator, symbol after)
  • United Kingdom (en-GB): £1,500 (comma separator, symbol before)
  • Japan (ja-JP): ¥1,500 (comma separator, symbol before)
Loading example...
import { Label } from '@wandercom/design-system-web/ui/label';

const [usValue, setUsValue] = useState<number | null>(1500);
const [deValue, setDeValue] = useState<number | null>(1500);
const [gbValue, setGbValue] = useState<number | null>(1500);
const [jpValue, setJpValue] = useState<number | null>(1500);

<div className="flex flex-col gap-4">
  <div className="flex flex-col gap-2">
    <Label htmlFor="us-input">United States (en-US)</Label>
    <NumberInput
      id="us-input"
      value={usValue}
      min={0}
      max={10000}
      step={100}
      currency="USD"
      locale="en-US"
      onChange={setUsValue}
    />
  </div>
  <div className="flex flex-col gap-2">
    <Label htmlFor="de-input">Germany (de-DE)</Label>
    <NumberInput
      id="de-input"
      value={deValue}
      min={0}
      max={10000}
      step={100}
      currency="EUR"
      locale="de-DE"
      onChange={setDeValue}
    />
  </div>
  <div className="flex flex-col gap-2">
    <Label htmlFor="gb-input">United Kingdom (en-GB)</Label>
    <NumberInput
      id="gb-input"
      value={gbValue}
      min={0}
      max={10000}
      step={100}
      currency="GBP"
      locale="en-GB"
      onChange={setGbValue}
    />
  </div>
  <div className="flex flex-col gap-2">
    <Label htmlFor="jp-input">Japan (ja-JP)</Label>
    <NumberInput
      id="jp-input"
      value={jpValue}
      min={0}
      max={10000}
      step={100}
      currency="JPY"
      locale="ja-JP"
      onChange={setJpValue}
    />
  </div>
</div>

Props

value?:

number | null
The current value of the input. When null, displays the placeholder. Defaults to null.

min?:

number
The minimum allowed value. Defaults to 0.

max?:

number
The maximum allowed value. Defaults to 100.

step?:

number
The increment/decrement step value. Defaults to 1.

placeholder?:

string
Text displayed when value is null. Defaults to "Any".

currency?:

string
Currency symbol or ISO 4217 code (e.g., "$", "USD", "EUR"). Displays before the value.

locale?:

string
BCP 47 locale tag for number formatting (e.g., "en-US", "de-DE", "fr-FR"). Defaults to "en-US".

onChange?:

(value: number | null) => void
Callback fired when the value changes via the increment/decrement buttons.

onFocus?:

() => void
Callback fired when the spinbutton receives focus.

onBlur?:

() => void
Callback fired when the spinbutton loses focus.

disabled?:

boolean
Whether the input is disabled. Defaults to false.

required?:

boolean
Whether the input is required in forms. Defaults to false.

aria-label?:

string
Accessible label for the spinbutton.

aria-labelledby?:

string
ID of the element that labels the spinbutton.

aria-describedby?:

string
ID of the element that describes the spinbutton.

id?:

string
Optional ID for the spinbutton element. Auto-generated if not provided.

name?:

string
Form field name. When provided, renders a hidden input for form submission.

className?:

string
Additional CSS classes to apply to the container.

Accessibility

This component follows Radix UI patterns and WAI-ARIA Spinbutton specification:

Keyboard Navigation:

  • Enter / Space - Enter edit mode for manual input
  • Arrow Up / Arrow Down - Increment/decrement by step
  • Page Up / Page Down - Increment/decrement by step × 10
  • Home / End - Jump to min/max values
  • Tab - Move focus to/from the spinbutton
  • Escape - Exit edit mode without saving (when editing)

Screen Reader Support:

  • Uses role="spinbutton" with proper ARIA attributes
  • Announces current value, min, and max to screen readers
  • Buttons are excluded from tab order (only spinbutton is focusable)
  • Currency symbol is hidden from screen readers via aria-hidden

Form Integration:

  • Optional hidden input for native form submission when name prop is provided
  • Supports required and disabled states

Behavior

Increment/decrement buttons adjust the value by the step amount, respecting min and max bounds. When the value is null (placeholder state), pressing increment sets the value to min rather than min + step.

Click the value or press Enter/Space to enter edit mode for manual keyboard input.

Manual input supports direct number entry. Press Enter to confirm or Escape to cancel.

Buttons are disabled when the value reaches the minimum or maximum limit.

Placeholder display shows custom text when value is null, useful for "Any" or "None" states.

Values are automatically clamped to stay within the min and max range.

International formatting uses Intl.NumberFormat to display numbers with locale-specific thousands separators and currency symbols.

Focus management ensures the spinbutton receives focus for keyboard navigation while buttons remain clickable.

NumberInput