# Number Input

> A React number input to type or step values: steppers with hold to repeat, min, max and step, and Intl formatting for money and units.

- Type: React component (Inputs)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add number-input`
- Docs: https://wingo-ui.com/components/number-input

## Interaction

Type a number the way you write it: 1.234,5 in Romanian or 1,234.5 in English both work, a comma or a dot is taken as the decimal, and letters are simply refused (a refused key typed over a selection keeps it selected). At rest the field shows the formatted value ($125,000, 2.5 kg); focus it and it turns into the plain number, ready to edit, and Tab selects it all like any field. Press + or - to step, and hold either one: after a short pause it repeats, faster the longer you hold, and stops at min or max, where that button dims. ArrowUp and ArrowDown step, Shift or PageUp and PageDown take the large step, Home and End jump to min and max, Enter commits and still submits the form, Escape puts back the last committed value. With the field focused the mouse wheel steps too, without scrolling the page. A number typed past a bound shows the red ring until you leave the field, where it snaps back inside (or cannot be typed at all with strict clamping). On a phone the keypad is numeric (decimal when the step allows decimals), the text is 16px so iOS never zooms, the md box grows to 44px, each + and - has a 44px hit area (the stacked chevrons turn into a row of two 44px buttons), and a finger that starts scrolling on a stepper undoes its step. iOS keypads have no minus key, so negative values come from the steppers.

## Installation

```bash
npx wingo-ui@latest add number-input
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/number-input` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/number-input.tsx`

npm dependencies: `npm i motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Field](https://wingo-ui.com/components/field) ([Markdown](https://wingo-ui.com/components/field.md))
- [Number format](https://wingo-ui.com/components/number-format) ([Markdown](https://wingo-ui.com/components/number-format.md))
- [useLocale](https://wingo-ui.com/components/use-locale) ([Markdown](https://wingo-ui.com/components/use-locale.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { useState } from "react"
import { NumberInput } from "@/components/ui/number-input"

export function PropertyDetails() {
  const [area, setArea] = useState<number | null>(85)
  const [rooms, setRooms] = useState<number | null>(3)

  return (
    <form className="flex flex-col gap-4">
      <NumberInput
        name="area"
        label="Floor area"
        suffix="m²"
        min={10}
        max={1000}
        value={area}
        onValueChange={setArea}
        locale="en-US"
        required
      />
      <NumberInput name="rooms" label="Rooms" steppers="split" min={1} max={10} value={rooms} onValueChange={setRooms} />
      <NumberInput
        name="price"
        label="Price"
        currency="USD"
        locale="en-US"
        step={1000}
        decimals={0}
        defaultValue={485000}
        onValueCommit={(price) => console.log("Price saved:", price)}
      />
    </form>
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label?` | `ReactNode` | `""` | Renders the field anatomy around the box and links it to the input with htmlFor. Leave it empty for a bare field. |
| `description?` | `ReactNode` | `""` | Helper text under the box, linked with aria-describedby. The error takes its place while it shows. |
| `error?` | `ReactNode` | `""` | Replaces the description with the animated error and marks the field invalid (red ring, aria-invalid). |
| `required?` | `boolean` | `false` | The mark after the label, and required on the input, so native form validation stops an empty submit. |
| `invalid?` | `boolean` | `false` | The invalid look and aria-invalid without a message. A value outside min and max (typed or controlled) sets it on its own until it is back in range. |
| `disabled?` | `boolean` | `false` | Dims the field to 50%, blocks typing and the steppers, and leaves the hidden form input out of the submit. |
| `readOnly?` | `boolean` | `false` | The formatted value stays visible and selectable; the steppers, keys and the wheel stop and the steppers hide. |
| `placeholder?` | `string` | `""` | Hint text while the field is empty. |
| `prefix?` | `ReactNode` | `""` | Text or an element before the number, like €. A click on it lands in the input. |
| `suffix?` | `ReactNode` | `""` | Text or an element after the number: m², buc., %, kg. With steppers split it hugs the value in the middle. |
| `incrementLabel?` | `string` | `"Increase"` | The + button's accessible name and hover title. The demo passes Increase. |
| `decrementLabel?` | `string` | `"Decrease"` | The - button's accessible name and hover title. The demo passes Decrease. |
| `name?` | `string` | `""` | Renders a hidden input with this name and the value as "1234.5" (dot decimal, empty when null), so FormData and plain form posts get a number, not the formatted text. The visible input has no name. |
| `size?` | `"sm" \| "md" \| "lg"` | `"md"` | 32 / 40 / 48px, like a button. md grows to 44px on touch screens and the text is 16px on phones so iOS never zooms. |
| `variant?` | `"soft" \| "outline" \| "underline" \| "ghost"` | `"soft"` | The box: a soft fill, an outline, a bottom border that grows a bar on focus, or ghost for tables and toolbars. |
| `radius?` | `"none" \| "sm" \| "md" \| "lg" \| "full"` | `"md"` | Corner shape, scaled per size like the button (md is 8 / 10 / 12px). Ignored by underline. |
| `steppers?` | `"end" \| "split" \| "stacked" \| "none"` | `"end"` | end: - and + at the end. split: - at the start and + at the end with the value centered, the best layout for quantities on a phone. stacked: small chevrons in a column at the end, the desktop classic; on touch screens the column turns into a row of two 44px chevron buttons. none: keys and the wheel only. |
| `min?` | `number` | `-9007199254740991` | The lowest value. Steppers and keys stop here (the - button dims), Home jumps here, typed values clamp to it on blur, and a value below it is flagged invalid. Unbounded by default (the safe integer range). min above max is swapped with a dev warning. |
| `max?` | `number` | `9007199254740991` | The highest value, the same way: + dims at max, End jumps here, typed values clamp to it on blur. |
| `step?` | `number` | `1` | What + / - and the arrows add, counted from min, so an off-grid value first lands on the next multiple. It also sets the precision (0.1 keeps one decimal) unless decimals is set, and the keypad (numeric for whole steps, decimal otherwise). |
| `largeStep?` | `number` | `10` | What Shift + arrows, PageUp and PageDown (and Shift + wheel) add. |
| `decimals?` | `number` | `-1` | Fraction digits kept: typing more is refused and values round to it. The default -1 takes them from step and format (2 for a USD price, 1 for step 0.1, 0 for step 1). |
| `clampBehavior?` | `"blur" \| "strict" \| "none"` | `"blur"` | blur: out-of-range typing is allowed (the red ring shows) and clamped when the field loses focus. strict: a keystroke that can only move further out of range is refused. none: never clamped, validate it yourself. |
| `holdToRepeat?` | `boolean` | `true` | Holding a stepper repeats after 400ms, speeding up from every 100ms to every 30ms, and stops at min or max. |
| `wheel?` | `boolean` | `true` | The mouse wheel or trackpad steps while the field is focused and hovered (and the page does not scroll). Touch screens have no wheel. |
| `selectOnFocus?` | `boolean` | `false` | Selects the whole number when the field takes focus, clicks included. Keyboard focus selects it either way, like a native field. |
| `formatOnBlur?` | `boolean` | `true` | Shows the formatted value at rest ("$1,234.50") and the plain number while editing ("1234.5"). Off, the plain number always shows. |
| `locale?` | `string` | `""` | Formats and parses with this locale: separators, the currency symbol and its position. Empty uses the nearest LocaleProvider, then the page language. The demo passes "en-US". |
| `currency?` | `string` | `""` | An ISO code like "USD", "EUR" or "RON": shorthand for a currency format with the narrow symbol ($1,234.50, 1.234,50 €). |
| `value?` | `number \| null` |  | The controlled value; null is an empty field. A value outside min and max is shown as given and flagged invalid, never clamped silently. |
| `defaultValue?` | `number \| null` | `"null"` | The starting value while uncontrolled; null is an empty field. A form reset returns to it. |
| `onValueChange?` | `(value: number \| null) => void` |  | Every change: live while typing (out of range until blur with clampBehavior blur), every stepper tick and key, the wheel. null when the field is empty or holds only a minus. |
| `onValueCommit?` | `(value: number \| null) => void` |  | Once per edit, for expensive updates: on blur, on Enter, when a held stepper is released and when a stepping key goes up. Only when the value changed. |
| `onChange?` | `(event: ChangeEvent<HTMLInputElement>) => void` |  | The native input event, for keystrokes the field accepts. |
| `format?` | `Intl.NumberFormatOptions` |  | How the value reads at rest: { style: "unit", unit: "kilogram" } for "2,5 kg", { style: "percent" } for fractions (0.15 shows 15% and is typed as 15), { useGrouping: false } for years. Invalid options fall back to plain numbers. |
| `leftIcon?` | `ReactNode` |  | An icon at the start of the box (inside the centered group with steppers split). Unsized svgs follow the size. |
| `incrementIcon?` | `ReactNode` |  | Replaces the + (or the up chevron when stacked). |
| `decrementIcon?` | `ReactNode` |  | Replaces the - (or the down chevron when stacked). |
| `fieldProps?` | `Partial<FieldProps>` |  | Anything else for the Field wrapper: optional and optionalLabel, labelAction, orientation, labelWidth, hideLabel. |
| `id?` | `string` |  | The input's id. Defaults to useId(); inside a <Field> the Field's id wins. |
| `ref?` | `RefObject<HTMLInputElement \| null> or (node) => void` |  | The native input (role spinbutton, with aria-valuenow, min, max and valuetext). Every other native prop lands there too: inputMode (decimal or numeric by precision), enterKeyHint (done), autoComplete (off), onBlur, onKeyDown, aria-* and data-*. |
| `classNames?` | `Partial<Record<"field" \| "label" \| "description" \| "error" \| "counter" \| "box" \| "input" \| "icon" \| "prefix" \| "suffix" \| "steppers" \| "increment" \| "decrement", string>>` |  | Extra classes for the inner parts. box targets the field box, input the native element, steppers the stepper group or column. |
| `className?` | `string` |  | Classes for the outermost element: the Field root when there is a label, description or error, else the box. |

## Examples

- **Stepper layouts**: end, split for quantities, stacked chevrons for dense desktop forms, or none (keys and wheel only).
- **Currency**: currency="RON" in ro-RO reads 1.234,50 lei at rest and 1234,5 while editing; the same value in en-US USD.
- **Decimals and units**: step 0.1 keeps one decimal and a kilogram unit format; a percentage from 0 to 100 in steps of 5.
- **Hold to repeat**: Hold a stepper: it repeats after 400ms and speeds up. Every key a spinbutton should have works too.
- **Clamping**: blur lets you type past a bound (the error shows) and clamps on blur; strict refuses the keystroke.
- **Cart row**: Small split steppers with a pill radius, max from the stock, totals that roll.
- **Sizes and variants**: 32, 40 and 48px, and the four field boxes, so it lines up with every other input.
- **States**: Read-only hides the steppers, disabled dims, an out-of-range value from outside is flagged instead of clamped.

## See also

- [Field](https://wingo-ui.com/components/field) ([Markdown](https://wingo-ui.com/components/field.md))
- [Input](https://wingo-ui.com/components/input) ([Markdown](https://wingo-ui.com/components/input.md))
- [Slider](https://wingo-ui.com/components/slider) ([Markdown](https://wingo-ui.com/components/slider.md))
- [Number format](https://wingo-ui.com/components/number-format) ([Markdown](https://wingo-ui.com/components/number-format.md))
- [useLocale](https://wingo-ui.com/components/use-locale) ([Markdown](https://wingo-ui.com/components/use-locale.md))

---

Canonical: https://wingo-ui.com/components/number-input
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
