Number input
A number field with quiet chevron steppers, arrow-key stepping, and bounds enforced on blur.
Controlled when value is set, uncontrolled otherwise; an empty field is null, not zero. The text is the source of truth while it is being typed, so a half-written decimal survives the trip through a number, and min and max are only applied on blur — a value can be retyped from the middle without the field fighting back. Steppers round to the decimals of the step, so 0.1 plus 0.2 is 0.3. Every prop the input takes passes through to the input itself; className sizes the field around it. parseNumeric is exported for reading typed amounts elsewhere. aria-valuetext reads out what the field shows, affixes included, so a currency field announces $1,234.50 rather than the bare number behind it. The steppers are deliberately small and out of the tab order — they are the field's trim, not its control — so ArrowUp and ArrowDown, and the field itself, are the targets that matter. At a bound only the stepper that has run out is disabled and dimmed; the field dims as a whole only when it is disabled itself. It stands on the card plane in both themes, as Input does, and so do CurrencyInput and SliderInput's field, which are built on it.
Install
npx shadcn@latest add @vibra/number-inputNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { Label } from "@/components/ui/label"
import { NumberInput } from "@/components/ui/number-input"
export default function NumberInputDemo() {
const [seats, setSeats] = React.useState<number | null>(12)
const [rateLimit, setRateLimit] = React.useState<number | null>(2500)
const [threshold, setThreshold] = React.useState<number | null>(0.8)
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="seats">Seats</Label>
<NumberInput id="seats" value={seats} onValueChange={setSeats} min={1} max={250} />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="rate-limit">Rate limit</Label>
<NumberInput
id="rate-limit"
value={rateLimit}
onValueChange={setRateLimit}
min={100}
max={100_000}
step={500}
suffix="req/min"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="alert-threshold">Alert threshold</Label>
<NumberInput
id="alert-threshold"
size="sm"
value={threshold}
onValueChange={setThreshold}
min={0}
max={1}
step={0.05}
precision={2}
hideControls
/>
</div>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| data-slot | string | "number-input" | The slot name the field's root reports; components built on NumberInput set their own. |
| value | number | null | — | The current number; setting it makes the field controlled. |
| defaultValue | number | null | — | The starting number for an uncontrolled field. |
| onValueChange | (value: number | null) => void | — | Called with the new number, or null once the field is empty. |
| min | number | — | Lower bound, applied on blur and to the steppers. |
| max | number | — | Upper bound, applied on blur and to the steppers. |
| step | number | 1 | How much a stepper or an arrow key moves the value. |
| precision | number | — | Decimal places every committed value is rounded to; left out, typed decimals are kept as typed. |
| prefix | React.ReactNode | — | Sits inside the field before the number, e.g. a currency symbol. |
| suffix | React.ReactNode | — | Sits inside the field after the number, e.g. a unit. |
| size | "sm" | "default" | "default" | sm drops the field to h-7 for dense forms. |
| format | (value: number) => string | grouped digits | How the number reads while the field is not focused; typing always shows raw digits. |
| hideControls | boolean | false | Drops the steppers; the arrow keys still work. |
Dependencies
Registry
npm
Source
"use client"
import * as React from "react"
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { clamp, formatNumber } from "@/lib/format"
import { Input } from "@/components/ui/input"
/** Reads a number out of typed text, keeping only digits, a minus, and a decimal point; returns null when what is left is not a number ("", "-", "1.2.3"). */
export function parseNumeric(text: string): number | null {
const cleaned = text.replace(/[^0-9.-]/g, "")
if (!cleaned) return null
const parsed = Number(cleaned)
return Number.isFinite(parsed) ? parsed : null
}
/** How many decimal places a number is written with, e.g. 0.25 → 2. */
function decimalsOf(value: number): number {
const text = String(value)
const point = text.indexOf(".")
return point === -1 ? 0 : text.length - point - 1
}
function roundTo(value: number, decimals: number): number {
const factor = 10 ** decimals
return Math.round(value * factor) / factor
}
// "prefix" joins the omitted names because <input>'s own prefix attribute is an
// RDFa string, and this one is a node rendered inside the field.
export type NumberInputProps = Omit<
React.ComponentProps<"input">,
"value" | "onChange" | "size" | "min" | "max" | "step" | "prefix"
> & {
/** The data-slot the field's root reports; components built on NumberInput set their own. */
"data-slot"?: string
value?: number | null
defaultValue?: number | null
onValueChange?: (value: number | null) => void
min?: number
max?: number
step?: number
/** Decimal places every committed value is rounded to; left out, typed decimals are kept as typed. */
precision?: number
/** Sits inside the field before the number, e.g. a currency symbol. */
prefix?: React.ReactNode
/** Sits inside the field after the number, e.g. a unit. */
suffix?: React.ReactNode
size?: "sm" | "default"
/** How the number reads while the field is not focused; typing always shows the raw digits. */
format?: (value: number) => string
hideControls?: boolean
}
/** A number field with quiet chevron steppers, arrow-key stepping, and bounds enforced on blur. */
function NumberInput({
className,
"data-slot": slot = "number-input",
value,
defaultValue,
onValueChange,
min,
max,
step = 1,
precision,
prefix,
suffix,
size = "default",
format,
hideControls = false,
disabled,
onBlur,
onFocus,
onKeyDown,
...props
}: NumberInputProps) {
const isControlled = value !== undefined
const [uncontrolled, setUncontrolled] = React.useState<number | null>(defaultValue ?? null)
const current = isControlled ? (value ?? null) : uncontrolled
// How the number reads at rest: grouped by default, or however `format` says.
const display = (n: number | null) =>
n === null ? "" : format ? format(n) : formatNumber(n, { maximumFractionDigits: 20 })
// How it reads under the caret: raw digits, so a caret can be put anywhere.
const raw = (n: number | null) => (n === null ? "" : String(n))
const [focused, setFocused] = React.useState(false)
const [text, setText] = React.useState(() => raw(current))
const [lastValue, setLastValue] = React.useState(current)
// The text is the source of truth while it is being typed — "1." must survive
// the round trip through 1 — so it is only rewritten when the incoming number
// is not the one the text already spells.
if (current !== lastValue) {
setLastValue(current)
if (parseNumeric(text) !== current) setText(raw(current))
}
const bound = (n: number) => clamp(n, min ?? -Infinity, max ?? Infinity)
const round = (n: number) => (precision === undefined ? n : roundTo(n, precision))
function commit(next: number | null) {
setText(raw(next))
if (!isControlled) setUncontrolled(next)
if (next !== current) onValueChange?.(next)
}
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
const nextText = event.target.value
setText(nextText)
const parsed = parseNumeric(nextText)
// Bounds are only enforced on blur, so a number can be retyped from the
// middle without the field fighting back.
const next = parsed === null ? null : round(parsed)
if (!isControlled) setUncontrolled(next)
if (next !== current) onValueChange?.(next)
}
function handleBlur(event: React.FocusEvent<HTMLInputElement>) {
setFocused(false)
const parsed = parseNumeric(event.target.value)
commit(parsed === null ? null : bound(round(parsed)))
onBlur?.(event)
}
function stepBy(direction: 1 | -1) {
const base = current ?? 0
const decimals = precision ?? Math.max(decimalsOf(step), decimalsOf(base))
commit(bound(roundTo(base + step * direction, decimals)))
}
function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key === "ArrowUp") {
event.preventDefault()
stepBy(1)
} else if (event.key === "ArrowDown") {
event.preventDefault()
stepBy(-1)
}
onKeyDown?.(event)
}
// What the field shows, affixes included, so a screen reader reads out
// "$1,234.50" rather than the bare 1234.5 behind it.
const valueText =
current === null
? undefined
: `${typeof prefix === "string" ? prefix : ""}${display(current)}${
typeof suffix === "string" ? ` ${suffix}` : ""
}`
const atMax = max !== undefined && current !== null && current >= max
const atMin = min !== undefined && current !== null && current <= min
return (
<div
data-slot={slot}
data-size={size}
data-disabled={disabled || undefined}
// On the card plane in both themes, as Input is. The field dims when its
// input is disabled — never because a stepper has run out at a bound,
// which dims that stepper alone.
className={cn(
"flex w-full min-w-0 items-center rounded-lg border border-input bg-card transition-colors has-[input:disabled]:opacity-50 has-[input:focus-visible]:focus-outline",
size === "sm" ? "h-7" : "h-8",
className
)}
>
{prefix ? (
<span
data-slot="number-input-prefix"
aria-hidden="true"
className="ps-2.5 text-sm text-muted-foreground select-none"
>
{prefix}
</span>
) : null}
<Input
type="text"
inputMode="decimal"
autoComplete="off"
role="spinbutton"
aria-valuenow={current ?? undefined}
aria-valuetext={valueText}
aria-valuemin={min}
aria-valuemax={max}
disabled={disabled}
value={focused ? text : display(current)}
onChange={handleChange}
onFocus={(event) => {
setFocused(true)
setText(raw(current))
onFocus?.(event)
}}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className={cn(
"h-full flex-1 rounded-none border-0 bg-transparent px-2.5 tabular-nums shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
// An affix already holds the gutter on its side, so the number sits
// beside it rather than a full field's padding away.
prefix ? "ps-1.5" : undefined,
suffix ? "pe-1.5" : undefined
)}
{...props}
/>
{suffix ? (
<span
data-slot="number-input-suffix"
aria-hidden="true"
className="pe-2.5 text-sm text-muted-foreground select-none"
>
{suffix}
</span>
) : null}
{hideControls ? null : (
<div
data-slot="number-input-controls"
className="flex h-full shrink-0 flex-col justify-center border-s border-input"
>
<StepperButton label="Increment" disabled={disabled || atMax} onClick={() => stepBy(1)}>
<ChevronUpIcon className="size-3" />
</StepperButton>
<StepperButton label="Decrement" disabled={disabled || atMin} onClick={() => stepBy(-1)}>
<ChevronDownIcon className="size-3" />
</StepperButton>
</div>
)}
</div>
)
}
// Half-height so the pair fits the field exactly; too small for any Button size,
// and quiet on purpose — the field is the control, these are its trim.
function StepperButton({
label,
...props
}: React.ComponentProps<"button"> & { label: string }) {
return (
<button
type="button"
tabIndex={-1}
aria-label={label}
data-slot="number-input-stepper"
// Keeps the caret in the field, so a run of clicks never bounces focus.
onMouseDown={(event) => event.preventDefault()}
className="flex h-1/2 w-5 items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-ring disabled:pointer-events-none disabled:opacity-40"
{...props}
/>
)
}
export { NumberInput }