Number Input React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
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 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.
Number Input is a Pro React component in the React input components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add number-input, together with Field, Number format, useLocale, Motion presets, useControllableState and 1 more and the npm packages motion and lucide-react.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add number-inputNumber Input is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import NumberInput from @/components/ui/number-input.
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
labeldefault""ReactNodeRenders the field anatomy around the box and links it to the input with htmlFor. Leave it empty for a bare field.
descriptiondefault""ReactNodeHelper text under the box, linked with aria-describedby. The error takes its place while it shows.
errordefault""ReactNodeReplaces the description with the animated error and marks the field invalid (red ring, aria-invalid).
requireddefaultfalsebooleanThe mark after the label, and required on the input, so native form validation stops an empty submit.
invaliddefaultfalsebooleanThe 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.
disableddefaultfalsebooleanDims the field to 50%, blocks typing and the steppers, and leaves the hidden form input out of the submit.
readOnlydefaultfalsebooleanThe formatted value stays visible and selectable; the steppers, keys and the wheel stop and the steppers hide.
placeholderdefault""stringHint text while the field is empty.
prefixdefault""ReactNodeText or an element before the number, like €. A click on it lands in the input.
suffixdefault""ReactNodeText or an element after the number: m², buc., %, kg. With steppers split it hugs the value in the middle.
incrementLabeldefault"Increase"stringThe + button's accessible name and hover title. The demo passes Increase.
decrementLabeldefault"Decrease"stringThe - button's accessible name and hover title. The demo passes Decrease.
namedefault""stringRenders 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.
sizedefault"md""sm" | "md" | "lg"32 / 40 / 48px, like a button. md grows to 44px on touch screens and the text is 16px on phones so iOS never zooms.
variantdefault"soft""soft" | "outline" | "underline" | "ghost"The box: a soft fill, an outline, a bottom border that grows a bar on focus, or ghost for tables and toolbars.
radiusdefault"md""none" | "sm" | "md" | "lg" | "full"Corner shape, scaled per size like the button (md is 8 / 10 / 12px). Ignored by underline.
steppersdefault"end""end" | "split" | "stacked" | "none"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.
mindefault-9007199254740991numberThe 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.
maxdefault9007199254740991numberThe highest value, the same way: + dims at max, End jumps here, typed values clamp to it on blur.
stepdefault1numberWhat + / - 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).
largeStepdefault10numberWhat Shift + arrows, PageUp and PageDown (and Shift + wheel) add.
decimalsdefault-1numberFraction 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).
clampBehaviordefault"blur""blur" | "strict" | "none"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.
holdToRepeatdefaulttruebooleanHolding a stepper repeats after 400ms, speeding up from every 100ms to every 30ms, and stops at min or max.
wheeldefaulttruebooleanThe mouse wheel or trackpad steps while the field is focused and hovered (and the page does not scroll). Touch screens have no wheel.
selectOnFocusdefaultfalsebooleanSelects the whole number when the field takes focus, clicks included. Keyboard focus selects it either way, like a native field.
formatOnBlurdefaulttruebooleanShows the formatted value at rest ("$1,234.50") and the plain number while editing ("1234.5"). Off, the plain number always shows.
localedefault""stringFormats 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".
currencydefault""stringAn ISO code like "USD", "EUR" or "RON": shorthand for a currency format with the narrow symbol ($1,234.50, 1.234,50 €).
valuenumber | nullThe controlled value; null is an empty field. A value outside min and max is shown as given and flagged invalid, never clamped silently.
defaultValuedefault"null"number | nullThe starting value while uncontrolled; null is an empty field. A form reset returns to it.
onValueChange(value: number | null) => voidEvery 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) => voidOnce 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>) => voidThe native input event, for keystrokes the field accepts.
formatIntl.NumberFormatOptionsHow 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.
leftIconReactNodeAn icon at the start of the box (inside the centered group with steppers split). Unsized svgs follow the size.
incrementIconReactNodeReplaces the + (or the up chevron when stacked).
decrementIconReactNodeReplaces the - (or the down chevron when stacked).
fieldPropsPartial<FieldProps>Anything else for the Field wrapper: optional and optionalLabel, labelAction, orientation, labelWidth, hideLabel.
idstringThe input's id. Defaults to useId(); inside a <Field> the Field's id wins.
refRefObject<HTMLInputElement | null> or (node) => voidThe 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-*.
classNamesPartial<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.
classNamestringClasses 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.
Files
- components/ui/number-input.tsx
FAQ
Is the React Number Input component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Number Input?
Run npx wingo-ui@latest add number-input. It also adds the Wingo UI items it builds on (Field, Number format, useLocale, Motion presets, useControllableState and 1 more) and the npm packages motion and lucide-react. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/number-input.
How does Number Input behave on phones?
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.
See also
Used in
More React input components
Articles using this component
- Shadcn Number Input: Steppers, Min, Max and Decimals
Component guides8 min read
- React Currency Input: Intl Formatting Without Caret Jumps
Component guides10 min read