rapidUI

Number Input

Numeric input with increment and decrement buttons and min/max/step.

Installation

pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/number-input.json

Installs to @/components/ui/number-input.

Manual installation

Install the dependencies:

pnpm add lucide-react@^1.48.0

Add the components it builds on: input-group.

Copy the source into your project:

components/ui/number-input.tsx
'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.

  • Input
  • Slider