Number Field
React formNumeric input with stepper buttons, min/max bounds, step size, and decimal precision. Use for quantities, prices, and any field that should be a number rather than free text.
Also available for Vue ->Installation
$ pnpm dlx shadcn@latest add https://react.uipkge.dev/r/react/number-field.json$ npx shadcn@latest add https://react.uipkge.dev/r/react/number-field.json$ yarn dlx shadcn@latest add https://react.uipkge.dev/r/react/number-field.json$ bunx shadcn@latest add https://react.uipkge.dev/r/react/number-field.json
Or with the named registry:
npx shadcn@latest add @uipkge-react/number-field
Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
value Controlled value. `undefined` renders an empty field. | number | — | optional |
defaultValue | number | — | optional |
onValueChange | (value: number | undefined) => void | — | optional |
min | number | — | optional |
max | number | — | optional |
step | number | — | optional |
precision Fixed number of decimal digits in the formatted display. | number | — | optional |
disabled | boolean | — | optional |
readOnly | boolean | — | optional |
size | NumberFieldSize | — | optional |
status | NumberFieldStatus | — | optional |
controlsPosition | NumberFieldControlsPosition | — | optional |
keyboard Enable Arrow/Page/Home/End keyboard stepping. | boolean | — | optional |
formatOptions Intl number-format options merged into the display formatter. | Intl.NumberFormatOptions | — | optional |
formatter Custom display formatter — wins over `formatOptions`/`precision`. | (value: number | undefined) => string | — | optional |
parser Custom parser from the typed text back to a number. | (displayValue: string) => number | undefined | — | optional |
prefix | string | — | optional |
suffix | string | — | optional |
placeholder | string | — | optional |
id | string | — | optional |
className | string | — | optional |
Dependencies
Files (2)
-
components/ui/number-field/number-field.tsx 11 kB
'use client' import * as React from 'react' import { Minus, Plus } from 'lucide-react' import { cn } from '@/lib/utils' export type NumberFieldSize = 'small' | 'middle' | 'large' export type NumberFieldStatus = 'error' | 'warning' export type NumberFieldControlsPosition = 'default' | 'right' export interface NumberFieldProps { /** Controlled value. `undefined` renders an empty field. */ value?: number defaultValue?: number onValueChange?: (value: number | undefined) => void min?: number max?: number step?: number /** Fixed number of decimal digits in the formatted display. */ precision?: number disabled?: boolean readOnly?: boolean size?: NumberFieldSize status?: NumberFieldStatus controlsPosition?: NumberFieldControlsPosition /** Enable Arrow/Page/Home/End keyboard stepping. */ keyboard?: boolean /** Intl number-format options merged into the display formatter. */ formatOptions?: Intl.NumberFormatOptions /** Custom display formatter — wins over `formatOptions`/`precision`. */ formatter?: (value: number | undefined) => string /** Custom parser from the typed text back to a number. */ parser?: (displayValue: string) => number | undefined prefix?: string suffix?: string placeholder?: string id?: string className?: string 'aria-label'?: string } const decrementIncrementBase = 'focus-visible:ring-ring inline-flex shrink-0 items-center justify-center transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-30' function clamp(value: number, min?: number, max?: number) { let next = value if (min !== undefined) next = Math.max(min, next) if (max !== undefined) next = Math.min(max, next) return next } const NumberField = React.forwardRef<HTMLInputElement, NumberFieldProps>( ( { value, defaultValue, onValueChange, min, max, step = 1, precision, disabled, readOnly, size = 'middle', status, controlsPosition = 'default', keyboard = true, formatOptions, formatter, parser, prefix, suffix, placeholder, id, className, 'aria-label': ariaLabel, }, ref, ) => { const innerRef = React.useRef<HTMLInputElement | null>(null) const setRefs = React.useCallback( (node: HTMLInputElement | null) => { innerRef.current = node if (typeof ref === 'function') ref(node) else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = node }, [ref], ) const isControlled = value !== undefined const [internal, setInternal] = React.useState<number | undefined>(defaultValue) const currentValue = isControlled ? value : internal const isRight = controlsPosition === 'right' const intlOptions = React.useMemo<Intl.NumberFormatOptions | undefined>(() => { if (precision !== undefined) { return { ...formatOptions, minimumFractionDigits: precision, maximumFractionDigits: precision } } return formatOptions }, [formatOptions, precision]) const formatValue = React.useCallback( (val: number | undefined): string => { if (formatter) return formatter(val) if (val === undefined || Number.isNaN(val)) return '' if (intlOptions) return new Intl.NumberFormat(undefined, intlOptions).format(val) return String(val) }, [formatter, intlOptions], ) const [displayValue, setDisplayValue] = React.useState<string>(() => formatValue(currentValue)) const [isUserTyping, setIsUserTyping] = React.useState(false) // Keep the display in sync with the value whenever the user isn't typing. React.useEffect(() => { if (!isUserTyping) setDisplayValue(formatValue(currentValue)) }, [currentValue, formatValue, isUserTyping]) const emit = React.useCallback( (next: number | undefined) => { if (!isControlled) setInternal(next) onValueChange?.(next) }, [isControlled, onValueChange], ) const stepBy = React.useCallback( (delta: number) => { if (disabled || readOnly) return const base = currentValue ?? 0 const next = clamp(base + delta, min, max) emit(next) setIsUserTyping(false) setDisplayValue(formatValue(next)) }, [currentValue, disabled, readOnly, min, max, emit, formatValue], ) const handleIncrease = (mult = 1) => stepBy(step * mult) const handleDecrease = (mult = 1) => stepBy(-step * mult) function commitValue() { setIsUserTyping(false) const raw = displayValue.trim() if (raw === '') { emit(undefined) setDisplayValue('') return } let num: number | undefined if (parser) num = parser(raw) else num = Number(raw) if (num !== undefined && !Number.isNaN(num)) { const next = clamp(num, min, max) emit(next) setDisplayValue(formatValue(next)) } else { setDisplayValue(formatValue(currentValue)) } } function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) { if (event.key === 'ArrowUp') { if (keyboard) { event.preventDefault() handleIncrease() } } else if (event.key === 'ArrowDown') { if (keyboard) { event.preventDefault() handleDecrease() } } else if (event.key === 'PageUp') { if (keyboard) { event.preventDefault() handleIncrease(10) } } else if (event.key === 'PageDown') { if (keyboard) { event.preventDefault() handleDecrease(10) } } else if (event.key === 'Home') { if (keyboard && min !== undefined) { event.preventDefault() emit(min) setIsUserTyping(false) setDisplayValue(formatValue(min)) } } else if (event.key === 'End') { if (keyboard && max !== undefined) { event.preventDefault() emit(max) setIsUserTyping(false) setDisplayValue(formatValue(max)) } } else if (event.key === 'Enter') { commitValue() } } function handleWheel(event: React.WheelEvent<HTMLInputElement>) { if (event.currentTarget !== document.activeElement) return if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return if (event.deltaY > 0) handleDecrease() else handleIncrease() } const iconSize = size === 'small' ? 'h-3 w-3' : size === 'large' ? 'h-5 w-5' : 'h-4 w-4' const buttonPadding = !isRight ? size === 'small' ? 'p-1.5' : size === 'large' ? 'p-4' : 'p-3' : '' const inputSizeClasses = size === 'small' ? 'h-7 text-xs px-2 py-0.5' : size === 'large' ? 'h-11 text-base px-4 py-2' : 'h-9 text-sm px-3 py-1' const inputStatusClasses = !isRight ? status === 'error' ? 'border-destructive focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 aria-invalid:border-destructive' : status === 'warning' ? 'border-[var(--warning)] focus-visible:ring-[var(--warning)]/20' : '' : '' const contentClasses = cn( 'relative', isRight && 'border-input focus-within:ring-ring inline-grid grid-cols-[1fr_auto] grid-rows-[1fr_1fr] items-stretch overflow-hidden rounded-md border focus-within:ring-1', ) const decrement = ( <button type="button" data-uipkge="" data-slot="decrement" tabIndex={-1} aria-label="Decrease" disabled={disabled || readOnly || (min !== undefined && (currentValue ?? 0) <= min)} onClick={() => handleDecrease()} className={cn( decrementIncrementBase, !isRight && 'absolute top-1/2 left-0 -translate-y-1/2', buttonPadding, isRight && 'hover:bg-accent col-start-2 row-start-2 h-full w-auto rounded-none border-t border-l p-0 px-2', )} > <Minus className={iconSize} aria-hidden="true" /> </button> ) const increment = ( <button type="button" data-uipkge="" data-slot="increment" tabIndex={-1} aria-label="Increase" disabled={disabled || readOnly || (max !== undefined && (currentValue ?? 0) >= max)} onClick={() => handleIncrease()} className={cn( decrementIncrementBase, !isRight && 'absolute top-1/2 right-0 -translate-y-1/2', buttonPadding, isRight && 'hover:bg-accent col-start-2 row-start-1 h-full w-auto rounded-none border-l p-0 px-2', )} > <Plus className={iconSize} aria-hidden="true" /> </button> ) const inputBlock = ( <div data-uipkge="" data-slot="input" className={cn( 'relative flex-1', isRight && 'col-span-1 row-span-2', // Non-right layout: pad the input to clear the overlaid steppers. !isRight && '[&>input]:pl-5 [&>input]:pr-5', )} > {prefix && ( <span className="text-muted-foreground pointer-events-none absolute top-1/2 left-2 -translate-y-1/2 text-sm"> {prefix} </span> )} <input id={id} ref={setRefs} value={displayValue} type="text" role="spinbutton" aria-label={ariaLabel} aria-valuenow={currentValue} aria-valuemin={min} aria-valuemax={max} aria-invalid={status === 'error' ? true : undefined} inputMode={precision !== undefined ? 'decimal' : 'numeric'} disabled={disabled} readOnly={readOnly} placeholder={placeholder} autoComplete="off" autoCorrect="off" spellCheck={false} aria-roledescription="Number field" onFocus={() => setIsUserTyping(true)} onChange={(e) => setDisplayValue(e.target.value)} onBlur={commitValue} onKeyDown={handleKeyDown} onWheel={handleWheel} className={cn( 'placeholder:text-muted-foreground w-full bg-transparent text-center shadow-sm transition-colors outline-none disabled:cursor-not-allowed disabled:opacity-50', !isRight && 'border-input focus-visible:ring-ring rounded-md border focus-visible:ring-1', isRight && 'rounded-none border-0 focus-visible:ring-0', prefix && 'pl-6', suffix && 'pr-6', inputSizeClasses, inputStatusClasses, )} /> {suffix && ( <span className="text-muted-foreground pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 text-sm"> {suffix} </span> )} </div> ) return ( <div data-uipkge="" data-slot="number-field" className={cn('inline-flex', className)}> <div className={contentClasses}> {decrement} {inputBlock} {increment} </div> </div> ) }, ) NumberField.displayName = 'NumberField' export { NumberField } -
components/ui/number-field/index.ts 0.2 kB
export { NumberField, type NumberFieldProps, type NumberFieldSize, type NumberFieldStatus, type NumberFieldControlsPosition, } from './number-field'
Raw manifest: https://react.uipkge.dev/r/react/number-field.json