Installation
pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/number-input.jsonInstalls to @/components/ui/number-input.
Manual installation
Install the dependencies:
pnpm add lucide-react@^1.48.0Add the components it builds on: input-group.
Copy the source into your project:
'use client'
import { MinusIcon, PlusIcon } from 'lucide-react'
import * as React from 'react'
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group'
import { cn } from '@/lib/utils'
export interface NumberInputProps
extends Omit<React.ComponentProps<'input'>, 'onChange' | 'defaultValue' | 'value' | 'min' | 'max'> {
value?: number
onChange?: (value: number) => void
min?: number
max?: number
defaultValue?: number | string
}
function NumberInput({
className,
onChange,
value,
min = 0,
max = 10,
defaultValue = 0,
disabled = false,
onKeyDown,
...props
}: Readonly<NumberInputProps>) {
const [internalValue, setInternalValue] = React.useState<number>(Number(defaultValue))
const currentValue = value ?? internalValue
const commit = (next: number) => {
onChange?.(next)
setInternalValue(next)
}
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const parsed = event.target.value === '' ? 0 : Number.parseInt(event.target.value, 10)
const next = Number.isNaN(parsed) ? 0 : parsed
commit(Math.min(max, Math.max(min, next)))
}
const increment = () => {
const next = currentValue + 1
if (next > max) return
commit(next)
}
const decrement = () => {
const next = currentValue - 1
if (next < min) return
commit(next)
}
return (
<InputGroup
data-slot="number-input"
data-disabled={disabled || undefined}
// The steppers disable themselves at min/max; only dim the group when the whole input is disabled.
className="has-[[data-slot=input-group-control]:read-only]:border-solid has-[[data-slot=input-group-control]:read-only]:bg-transparent has-disabled:bg-transparent has-disabled:opacity-100 data-disabled:opacity-50 dark:bg-transparent dark:has-disabled:bg-transparent"
>
<InputGroupAddon align="inline-start">
<InputGroupButton
size="icon-xs"
onClick={decrement}
disabled={currentValue <= min || disabled}
aria-label="Decrease value"
data-testid="decrement-button"
>
<span className="sr-only">Decrement number button</span>
<MinusIcon />
</InputGroupButton>
</InputGroupAddon>
<InputGroupInput
type="text"
inputMode="numeric"
value={currentValue}
onChange={handleInputChange}
onKeyDown={(event) => {
onKeyDown?.(event)
if (event.defaultPrevented) return
if (event.key === 'ArrowUp') {
event.preventDefault()
increment()
} else if (event.key === 'ArrowDown') {
event.preventDefault()
decrement()
}
}}
className={cn('text-center tabular-nums', className)}
aria-label="Number input"
disabled={disabled}
pattern="\d*"
data-testid="number-input"
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={currentValue}
role="spinbutton"
readOnly
{...props}
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
onClick={increment}
disabled={currentValue >= max || disabled}
aria-label="Increase value"
data-testid="increment-button"
>
<span className="sr-only">Increment number button</span>
<PlusIcon />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
)
}
export { NumberInput }A stepper-style number input. The field itself is read-only; the value changes through the decrement/increment
buttons (or the arrow up / down keys) and is clamped to min / max. Built from InputGroup, so it has the
same h-8, rounded-lg look, focus ring and aria-invalid state as Input.
Usage
import { NumberInput } from "@/components/ui/number-input"
// Uncontrolled
<NumberInput min={0} max={10} defaultValue={1} onChange={(value) => console.log(value)} />
// Controlled
const [qty, setQty] = useState(1)
<NumberInput min={1} max={99} value={qty} onChange={setQty} />Examples
Default
A labelled number input with a description. Pass id and aria-labelledby so the field is announced with its label.
With range
min and max clamp the value; the buttons disable themselves at the limits.
Controlled
A group room booking where the attendee count drives the summary in the footer.
In a form
Several number inputs in a waste collection subscription, with a running annual fee.
Disabled
A value fixed by a decision.
Related Components
- Input
- Slider