NumberInput
A numeric field with stacked step buttons at its end, for quantities, counts and amounts with a known step (cart quantity, guest count, seats). It is a text input (type text, inputmode decimal), never type number, so it can show formatted text such as 1.000 ₫. Use Input for free text, Slider when the exact value matters less than the range.
Preview
import { Field, FieldControl, FieldLabel } from "@/components/ui/field"
import { NumberInput } from "@/components/ui/number-input"
<Field className="w-full max-w-sm" invalid={false}>
<FieldLabel>Quantity</FieldLabel>
<FieldControl>
<NumberInput min={0} max={10} step={1} defaultValue={3} />
</FieldControl>
</Field>Usage
When to use
- Enter or adjust a quantity whose valid steps are known, such as seats, guests or items in a cart.
- Show a number with locale-aware formatting, such as a currency amount, while keeping it editable.
When not to use
- Enter an identifier such as a phone or card number. Use Input with numeric input mode.
- Pick a position where the range matters more than the exact number. Use Slider.
- Enter free-form text. Use Input.
Variants
size
Control height from size-element-sm/md/lg/xl. Pick per region; md is the default.
import { Field, FieldControl, FieldLabel } from "@/components/ui/field"
import { NumberInput } from "@/components/ui/number-input"
<Field className="w-full max-w-sm" invalid={false}>
<FieldLabel>Quantity</FieldLabel>
<FieldControl>
<NumberInput min={0} max={10} size="sm" step={1} defaultValue={3} />
</FieldControl>
</Field>
<Field className="w-full max-w-sm" invalid={false}>
<FieldLabel>Quantity</FieldLabel>
<FieldControl>
<NumberInput min={0} max={10} step={1} defaultValue={3} />
</FieldControl>
</Field>
<Field className="w-full max-w-sm" invalid={false}>
<FieldLabel>Quantity</FieldLabel>
<FieldControl>
<NumberInput min={0} max={10} size="lg" step={1} defaultValue={3} />
</FieldControl>
</Field>
<Field className="w-full max-w-sm" invalid={false}>
<FieldLabel>Quantity</FieldLabel>
<FieldControl>
<NumberInput min={0} max={10} size="xl" step={1} defaultValue={3} />
</FieldControl>
</Field>Examples
Quantity
import { Field, FieldControl, FieldLabel } from "@/components/ui/field"
import { NumberInput } from "@/components/ui/number-input"
<Field>
<FieldLabel>Quantity</FieldLabel>
<FieldControl>
<NumberInput defaultValue={1} min={1} max={10} />
</FieldControl>
</Field>Guests
import { NumberInput } from "@/components/ui/number-input"
<NumberInput defaultValue={2} min={1} max={16} size="lg" />Currency
import { NumberInput } from "@/components/ui/number-input"
<NumberInput defaultValue={1000} step={1000} min={0} locale="vi-VN" formatOptions={{ style: "currency", currency: "VND" }} />Weight
import { Field, FieldControl, FieldLabel } from "@/components/ui/field"
import { NumberInput } from "@/components/ui/number-input"
<Field>
<FieldLabel>Weight</FieldLabel>
<FieldControl>
<NumberInput defaultValue={12} min={0} trailingText="kg" />
</FieldControl>
</Field>Do and don't
Do
- Set min and max whenever the domain has them (a quantity is at least 1).
- Use inside Field so the label and error are linked.
- Use lg or xl on touch-first screens so the buttons are easy to hit.
Don't
- Do not use for numbers that are identifiers (card number, phone, postal code); use Input with inputmode numeric.
- Do not use for a range where position matters more than the value; use Slider.
- Do not fire network requests on every step; debounce in the app.
Props
| Name | Type | Values | Default | Description |
|---|---|---|---|---|
| value | number | null | - | - | The value when you control it. null is an empty field. |
| defaultValue | number | null | - | null | The value on first render when you do not control it. |
| onValueChange | (value: number | null) => void | - | - | Called with the committed number (after parse, clamp and step rounding) on a step key, a button press, Enter or blur, and with null when the field is cleared. Not called for text that does not parse (the field shows invalid instead) or on every keystroke. |
| min | number | - | - | Lowest value. Buttons and ArrowDown stop here. |
| max | number | - | - | Highest value. Must be greater than min. |
| step | number | - | 1 | Amount per step. |
| largeStep | number | - | - | Amount for PageUp and PageDown. Defaults to step times 10. |
| clampOnBlur | boolean | - | true | Clamp and round a typed value when the field loses focus. When false, an out-of-range value stays and the field shows invalid. |
| size | enum | sm | md | lg | xl | md | Control height from size-element-sm/md/lg/xl. Pick per region; md is the default. |
| disabled | boolean | - | false | No typing, no buttons, disabled look. |
| readOnly | boolean | - | false | The value can be read, focused and copied but not changed; the step buttons are hidden and the field drops its fill. The inner input gets the native readOnly attribute (its bindings use :read-only); the root group sets data-read-only. |
| invalid | boolean | - | false | Sets aria-invalid and the danger border. |
| required | boolean | - | false | Native required on the input. |
| name | string | - | - | Form field name; the submitted value is the plain number, not the formatted text. |
| showStepButtons | boolean | - | true | Show the decrement and increment buttons. Hide them for dense tables where arrow keys are enough. |
| decrementLabel | string | - | Decrease | Accessible name of the decrement button. Vietnamese: Giảm. |
| incrementLabel | string | - | Increase | Accessible name of the increment button. Vietnamese: Tăng. |
| locale | string | - | - | BCP 47 locale for formatting and for the separators used in parsing. Defaults to the document language. |
| formatOptions | Intl.NumberFormatOptions | - | - | How the value is shown when the field is not being edited and in aria-valuetext: currency, percent, unit, fraction digits. While focused the field shows the plain localized number. |
Slots
| Name | Required | Description |
|---|---|---|
| leadingText | no | Fixed text before the number, such as a currency. A sunken segment edge to edge, split from the value by a border-border line, and the field is described by it (same as Input leadingText). |
| trailingText | no | Fixed text after the number, such as kg. A sunken segment edge to edge, after the step buttons, split from them by a border-border line (same as Input trailingText). |
Anatomy
| Part | Description |
|---|---|
| root | The group that draws the field box: border, fill, corners, focus ring through :has(). The step buttons sit inside it at the end. |
| input | The text input with role spinbutton, font-size-input, tabular numbers, aligned to the start (left in LTR, right in RTL), with the step buttons at the end. |
| decrement | The lower ghost icon button of the stack at the end of the value (Tabler chevron down, 16px), just before any trailing text. |
| increment | The upper ghost icon button of the stack at the end of the value (Tabler chevron up, 16px), just before any trailing text. |
| placeholder | The placeholder text of the input, in secondary text like Input. |
Accessibility
Role: spinbutton
| Keys | Action |
|---|---|
| ArrowUp / ArrowDown | Increase or decrease by step, clamped to min and max. |
| PageUp / PageDown | Increase or decrease by largeStep. |
| Home / End | Set min or max when they are set. |
| Enter | Commit the typed text: parse, clamp, round to step. |
| Tab | Moves to the next field. The step buttons are skipped (tabIndex -1); the keys above cover them. |
- The input has type text, inputmode decimal (numeric when step is an integer and min is at least 0) and role spinbutton with aria-valuenow, aria-valuemin and aria-valuemax.
- aria-valuetext is the value formatted with formatOptions and locale, for example 1.000 ₫, so screen readers say the same text the field shows.
- Parsing: the group and decimal separators of the locale come from Intl.NumberFormat formatToParts. Group separators are stripped, the decimal separator maps to a dot, then the text is parsed. A string that does not parse sets the invalid state and does not call onValueChange.
- Clearing the field calls onValueChange(null).
- Step buttons are real buttons named by decrementLabel and incrementLabel, disabled at min and max, skipped by Tab (tabIndex -1) because the arrow keys cover them.
- Layout: the two buttons stack at the end of the value, increment on top, just before any trailing text, on a mouse and on touch. They draw no border. On touch the pair shares one size-target-min column. Each button takes only its own half of that column, so the two chevrons do not cover each other.
- Holding a step button repeats after a short delay; the announcement is the final value, not each step.
- invalid sets aria-invalid; Field passes its error with aria-describedby.
Install
npx werewolf-ui add number-inputRun npx werewolf-ui init once in your app before adding components. Init sets up tokens, their CSS import and agent rules. Add installs this component and its dependencies while preserving your theme. See Installation for requirements and the alternative registry setup.