Number Input
Numeric field with optional end or split steppers, typed decimals by default, and a headless React Aria hook.
Number Input is a controlled number | null field for quantities, scores, and other non-money numerics. Default chrome puts steppers in a trailing column (controls="end"); opt into split ± buttons or controls={false} for type-heavy ranges. Values commit through React Aria with commitBehavior="validate" so decimals such as 0.05 survive when step is 1.
For money amounts with an ISO currency selector, use Currency Input instead.
When To Use
- Capture a plain number (quantity, count, score, percent) where
number | nullis the form value. - Prefer
controls="end"(default) in Ant-density forms so the field keeps a standard input width. - Use
controls="split"for small bounded counts (guests, seats) where ± is the primary interaction. - Use
controls={false}for large free-range typing where steppers add noise. surface="tableCell"defaults tocontrols={false}(Excel / AG Grid / Ant table number cells). Passcontrols="end"to opt steppers back in. Do not put a second border on the input — Editable Table owns one field box. The field inherits the cell's alignment (text-endon the root for numeric columns) so typing matches display; form surfaces stay start-aligned.- Use Currency Input for money; do not overload Number Input with currency
formatOptions.
Interactions
| Event | Behavior |
|---|---|
| Type digits / decimals | Field stays editable (type="text"); no native spinner. |
| Blur / Enter | Commits parsed value; default commitBehavior="validate" keeps typed decimals (e.g. 0.05) even when step is 1. Opt into snap for integer-only fields that should snap to step on commit. |
| ArrowUp / ArrowDown | Step by step (default 1); clamped to min / max when set. |
| Home / End | Jump to min / max when defined. |
| Stepper click | Same as keyboard step; steppers stay out of tab order (tabIndex={-1}). |
At min / max | Matching stepper is disabled (visible, not removed). |
| Mouse wheel | Ignored (isWheelDisabled). |
| Empty commit | Emits null (not NaN). |
Default display is ungrouped (formatOptions.useGrouping: false). Pass formatOptions for grouping, percent, or units when needed.
Installing
pnpm dlx shadcn@latest add https://ui.isaacfei.com/r/number-input.jsonnpx shadcn@latest add https://ui.isaacfei.com/r/number-input.jsonyarn dlx shadcn@latest add https://ui.isaacfei.com/r/number-input.jsonbun x shadcn@latest add https://ui.isaacfei.com/r/number-input.jsonOr with a namespace: npx shadcn@latest add @f-ui/number-input.
The CLI installs @react-aria/button, @react-aria/i18n, @react-aria/numberfield, @react-stately/numberfield, and lucide-react, and pulls field-control-shell and field-disabled-surface via registryDependencies.
Usage
import { NumberInput } from "@/components/f-ui/number-input/number-input";
<NumberInput
value={qty}
onValueChange={setQty}
min={0}
aria-label="Quantity"
/>Examples
Default End Steppers
Controlled field with the default trailing chevron column. Type a value or use the steppers; the readout below shows the committed number | null.
value: 12
"use client";
import { useState } from "react";
import { NumberInput } from "@/components/f-ui/number-input/number-input";
export function NumberInputDemo() {
const [value, setValue] = useState<number | null>(12);
return (
<div className="max-w-xs space-y-2">
<NumberInput
value={value}
onValueChange={setValue}
aria-label="Quantity"
/>
<p className="text-muted-foreground text-xs tabular-nums">
value: {value ?? "null"}
</p>
</div>
);
}Split Controls
controls="split" with min={0} and max={99} for guest-count style UIs. The field is centered; ± buttons sit on either side.
value: 2
"use client";
import { useState } from "react";
import { NumberInput } from "@/components/f-ui/number-input/number-input";
export function NumberInputSplitDemo() {
const [value, setValue] = useState<number | null>(2);
return (
<div className="max-w-xs space-y-2">
<NumberInput
controls="split"
min={0}
max={99}
value={value}
onValueChange={setValue}
aria-label="Guest count"
/>
<p className="text-muted-foreground text-xs tabular-nums">
value: {value ?? "null"}
</p>
</div>
);
}No Controls
controls={false} for type-heavy or wide-range values where steppers would get in the way.
value: 1250
"use client";
import { useState } from "react";
import { NumberInput } from "@/components/f-ui/number-input/number-input";
export function NumberInputNoControlsDemo() {
const [value, setValue] = useState<number | null>(1250);
return (
<div className="max-w-xs space-y-2">
<NumberInput
controls={false}
value={value}
onValueChange={setValue}
aria-label="Score"
/>
<p className="text-muted-foreground text-xs tabular-nums">
value: {value ?? "null"}
</p>
</div>
);
}Min and Max Bounds
At the lower bound the decrement stepper disables; at the upper bound the increment stepper disables. Values outside the range clamp on commit.
At 0 the decrement stepper is disabled; at 5 the increment stepper is disabled. Try the steppers or type a value outside the range and blur.
value: 0
"use client";
import { useState } from "react";
import { NumberInput } from "@/components/f-ui/number-input/number-input";
export function NumberInputMinMaxDemo() {
const [value, setValue] = useState<number | null>(0);
return (
<div className="max-w-xs space-y-2">
<NumberInput
min={0}
max={5}
value={value}
onValueChange={setValue}
aria-label="Bounded quantity"
/>
<p className="text-muted-foreground text-xs">
At 0 the decrement stepper is disabled; at 5 the increment stepper is
disabled. Try the steppers or type a value outside the range and blur.
</p>
<p className="text-muted-foreground text-xs tabular-nums">
value: {value ?? "null"}
</p>
</div>
);
}Headless Usage
useNumberInput is the view-model: inputProps, stepper button props, and numberValue. The demo wires native <input> / <button> only — no f-ui classNames or stock parts.
value: 3
"use client";
import { useState } from "react";
import { useNumberInput } from "@/components/f-ui/number-input/use-number-input";
export function NumberInputHeadlessDemo() {
const [value, setValue] = useState<number | null>(3);
const {
inputRef,
groupProps,
inputProps,
incrementButtonProps,
decrementButtonProps,
numberValue,
} = useNumberInput({
value,
onValueChange: setValue,
min: 0,
max: 10,
"aria-label": "Headless quantity",
});
return (
<div>
<div {...groupProps}>
<button type="button" {...decrementButtonProps}>
−
</button>
<input ref={inputRef} {...inputProps} />
<button type="button" {...incrementButtonProps}>
+
</button>
</div>
<p>value: {numberValue ?? "null"}</p>
</div>
);
}Composition
NumberInput
└── root (div)
└── NumberInputGroup
├── [split] NumberInputStepperButton (−)
├── NumberInputField
├── [split] NumberInputStepperButton (+)
└── [end] stepper column
├── NumberInputStepperButton (↑)
└── NumberInputStepperButton (↓)With controls={false}, only NumberInputField renders inside the group.
Formily
Prefer FormField kind="number" — that is the first-class path (same registry as Editable Table field: true). It wires value / onValueChange as number | null (empty and clear commit null, never a mid-edit string). The Open default commitBehavior="validate" keeps typed decimals such as 0.05 when step is 1.
<FormField name="qty" kind="number" label="Qty" componentProps={{ min: 0, step: 1 }} />Do not use kind="text" / connected Input with type: "number". Schema JSON must set x-component: "NumberInput" (Formily type: "number" alone does not pick the control). Raw Formily Field wiring remains available for fully custom layouts.
API Reference
Props
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | null | — | Controlled value. |
defaultValue | number | null | — | Uncontrolled initial value. |
onValueChange | (value: number | null) => void | — | Fires on commit (blur / step). Empty → null. |
min / max | number | — | Inclusive bounds; disable matching steppers at edges. |
step | number | 1 | Step size for steppers and Arrow keys. |
formatOptions | Intl.NumberFormatOptions | { useGrouping: false } | Display / parse options. |
commitBehavior | "validate" | "snap" | "validate" | validate keeps typed decimals when step is coarse (e.g. 0.05 with step: 1). snap snaps commits to step (Aria default; use for integer-only fields). |
controls | "end" | "split" | false | "end" on default/compact; false on tableCell | Stepper layout. |
surface | "default" | "compact" | "tableCell" | "default" | Shell density. tableCell is borderless; the table decorator owns the field box. |
placeholder | string | — | Field placeholder. |
disabled / readOnly | boolean | false | — |
id / name | string | — | — |
aria-label / aria-labelledby / aria-describedby | string | — | — |
aria-invalid | boolean | — | — |
onFocus / onBlur | focus handlers | — | — |
className | string | — | Outer root. |
classNames | Partial<Record<NumberInputSlot, string>> | — | Per-slot overrides. |
Slots
| Slot | Applied to |
|---|---|
root | Outer wrapper |
group | Bordered NumberInputGroup shell |
field | Editable NumberInputField |
increment | Increment stepper button |
decrement | Decrement stepper button |
Hook — useNumberInput(options)
Same value / bound / format / commit options as the container (without controls, surface, classNames). Returns:
| Return | Role |
|---|---|
inputRef | Attach to your <input> |
numberValue | Committed number | null |
canIncrement / canDecrement | Bound state for custom chrome |
groupProps | Spread on the wrapping group element |
inputProps | Spread on <input> (type="text") |
incrementButtonProps / decrementButtonProps | Spread on stepper buttons (tabIndex={-1}, type="button") |