Number Field
number-fielduiNumeric 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.
Installation
$pnpm dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/number-field.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/number-field.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/number-field.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/number-field.jsonManifest:
https://uipkge.dev/r/svelte/number-field.jsonInstalls to:src/lib/components/ui/number-field/Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
value | number | undefined | — | optional |
defaultValue | number | undefined | — | optional |
min | number | — | optional |
max | number | — | optional |
step | number | 1 | optional |
disabled | boolean | false | optional |
readOnlyPrimary readonly API (React `readOnly` parity). Wins over `readonly` when both are set. | boolean | — | optional |
readonly@deprecated Use `readOnly`. Kept as an alias — both stay functional. | boolean | false | optional |
required | boolean | false | optional |
name | string | — | optional |
idForwarded to the inner input (React parity). To style or anchor the root container, wrap in a styled element. | string | — | optional |
placeholderForwarded to the inner input (React parity). | string | — | optional |
inputMode | 'decimal' | 'numeric' | decimal | optional |
formatOptions | Intl.NumberFormatOptions | — | optional |
disableWheelChange | boolean | false | optional |
invertWheelChange | boolean | false | optional |
size | NumberFieldSize | middle | optional |
status | NumberFieldStatus | — | optional |
controlsPosition | NumberFieldControlsPosition | default | optional |
keyboard | boolean | true | optional |
precision | number | — | optional |
formatter | (value: number | undefined) => string | — | optional |
parser | (displayValue: string) => number | undefined | — | optional |
prefix | string | — | optional |
suffix | string | — | optional |
ref | HTMLDivElement | null | — | optional |
onValueChange | (value: number | undefined) => void | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
NumberFieldContextValueinterface NumberFieldContextValue {
getValue: () => number | undefined
setValue: (value: number | undefined) => void
handleIncrease: (times?: number) => void
handleDecrease: (times?: number) => void
handleMinMaxValue: (which: 'min' | 'max') => void
applyInputValue: (displayValue: string) => void
formatValue: (value: number | undefined) => string
getMin: () => number | undefined
getMax: () => number | undefined
isDisabled: () => boolean
isReadonly: () => boolean
getInputMode: () => 'decimal' | 'numeric'
isWheelChangeDisabled: () => boolean
isWheelChangeInverted: () => boolean
getSize: () => NumberFieldSize
getStatus: () => NumberFieldStatus | undefined
getControlsPosition: () => NumberFieldControlsPosition
isKeyboardEnabled: () => boolean
getFormatter: () => ((value: number | undefined) => string) | undefined
getParser: () => ((displayValue: string) => number | undefined) | undefined
getPrefix: () => string | undefined
getSuffix: () => string | undefined
getId: () => string | undefined
getPlaceholder: () => string | undefined
}npm dependencies
Files installed (7)
src/lib/components/ui/number-field/NumberField.svelte6 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' import type { NumberFieldControlsPosition, NumberFieldSize, NumberFieldStatus } from './NumberFieldContext' export type { NumberFieldControlsPosition, NumberFieldSize, NumberFieldStatus } export interface NumberFieldProps extends HTMLAttributes<HTMLDivElement> { value?: number | undefined defaultValue?: number | undefined min?: number max?: number step?: number disabled?: boolean /** Primary readonly API (React `readOnly` parity). Wins over `readonly` when both are set. */ readOnly?: boolean /** @deprecated Use `readOnly`. Kept as an alias — both stay functional. */ readonly?: boolean required?: boolean name?: string /** Forwarded to the inner input (React parity). To style or anchor the root container, wrap in a styled element. */ id?: string /** Forwarded to the inner input (React parity). */ placeholder?: string inputMode?: 'decimal' | 'numeric' formatOptions?: Intl.NumberFormatOptions disableWheelChange?: boolean invertWheelChange?: boolean size?: NumberFieldSize status?: NumberFieldStatus controlsPosition?: NumberFieldControlsPosition keyboard?: boolean precision?: number formatter?: (value: number | undefined) => string parser?: (displayValue: string) => number | undefined prefix?: string suffix?: string ref?: HTMLDivElement | null onValueChange?: (value: number | undefined) => void } </script> <script lang="ts"> import { cn } from '$lib/utils' import { setNumberFieldContext } from './NumberFieldContext' let { class: className, value = $bindable(), defaultValue, min, max, step = 1, disabled = false, readOnly, readonly = false, required = false, name, id, placeholder, inputMode = 'decimal', formatOptions, disableWheelChange = false, invertWheelChange = false, size = 'middle', status, controlsPosition = 'default', keyboard = true, precision, formatter, parser, prefix, suffix, children, ref = $bindable(null), onValueChange, ...restProps }: NumberFieldProps = $props() const effectiveReadonly = $derived(readOnly ?? readonly ?? false) // Uncontrolled mode: seed the bindable from defaultValue on first read. // ($bindable() with no parent binding is local state; assigning the default // once mirrors reka's defaultValue without fighting a controlled parent — // the guard only runs while the parent hasn't provided a value.) let seeded = false $effect.pre(() => { if (!seeded && value === undefined && defaultValue !== undefined) { value = defaultValue } seeded = true }) const resolvedFormatOptions = $derived.by((): Intl.NumberFormatOptions | undefined => { if (precision !== undefined) { return { ...formatOptions, minimumFractionDigits: precision, maximumFractionDigits: precision } } return formatOptions }) function clamp(v: number): number { let out = v if (min !== undefined) out = Math.max(min, out) if (max !== undefined) out = Math.min(max, out) return out } function roundToStepPrecision(v: number): number { // Avoid 0.1 + 0.2 style float drift on stepped changes. const decimals = Math.max( (String(step).split('.')[1] ?? '').length, precision ?? 0, (String(min ?? '').split('.')[1] ?? '').length, ) if (decimals === 0) return Math.round(v) const f = 10 ** decimals return Math.round(v * f) / f } function setValue(v: number | undefined) { if (disabled || effectiveReadonly) return const next = v === undefined ? undefined : clamp(v) value = next onValueChange?.(next) } function handleIncrease(times = 1) { if (disabled || effectiveReadonly) return const base = value ?? (min !== undefined ? min - step * times : 0) setValue(roundToStepPrecision(clamp(base + step * times))) } function handleDecrease(times = 1) { if (disabled || effectiveReadonly) return const base = value ?? (max !== undefined ? max + step * times : 0) setValue(roundToStepPrecision(clamp(base - step * times))) } function handleMinMaxValue(which: 'min' | 'max') { if (disabled || effectiveReadonly) return if (which === 'min' && min !== undefined) setValue(min) if (which === 'max' && max !== undefined) setValue(max) } function applyInputValue(displayValue: string) { if (disabled || effectiveReadonly) return const num = Number(displayValue) if (displayValue.trim() === '') setValue(undefined) else if (!Number.isNaN(num)) setValue(roundToStepPrecision(num)) } function formatValue(v: number | undefined): string { if (v === undefined || Number.isNaN(v)) return '' if (formatter) return formatter(v) if (resolvedFormatOptions) { try { return new Intl.NumberFormat(undefined, resolvedFormatOptions).format(v) } catch { return String(v) } } return String(v) } setNumberFieldContext({ getValue: () => value, setValue, handleIncrease, handleDecrease, handleMinMaxValue, applyInputValue, formatValue, getMin: () => min, getMax: () => max, isDisabled: () => disabled, isReadonly: () => effectiveReadonly, getInputMode: () => inputMode, isWheelChangeDisabled: () => disableWheelChange, isWheelChangeInverted: () => invertWheelChange, getSize: () => size, getStatus: () => status, getControlsPosition: () => controlsPosition, isKeyboardEnabled: () => keyboard, getFormatter: () => formatter, getParser: () => parser, getPrefix: () => prefix, getSuffix: () => suffix, getId: () => id, getPlaceholder: () => placeholder, }) </script> <div bind:this={ref} data-uipkge="" data-slot="number-field" class={cn('inline-flex', className)} {...restProps}> {@render children?.()} {#if name} <input type="hidden" {name} value={value ?? ''} {required} {disabled} /> {/if} </div>src/lib/components/ui/number-field/NumberFieldContent.svelte1 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface NumberFieldContentProps extends HTMLAttributes<HTMLDivElement> { ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' import { getNumberFieldContext } from './NumberFieldContext' let { class: className, children, ref = $bindable(null), ...restProps }: NumberFieldContentProps = $props() const uiContext = getNumberFieldContext() const isRight = $derived(uiContext.getControlsPosition() === 'right') </script> <div bind:this={ref} class={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', !isRight && '[&>[data-slot=input]]:has-[[data-slot=decrement]]:pl-9 [&>[data-slot=input]]:has-[[data-slot=increment]]:pr-9', className, )} {...restProps} > {@render children?.()} </div>src/lib/components/ui/number-field/NumberFieldContext.ts2.4 kB
import { getContext, setContext } from 'svelte' export type NumberFieldSize = 'small' | 'middle' | 'large' export type NumberFieldStatus = 'error' | 'warning' export type NumberFieldControlsPosition = 'default' | 'right' export interface NumberFieldContextValue { getValue: () => number | undefined setValue: (value: number | undefined) => void handleIncrease: (times?: number) => void handleDecrease: (times?: number) => void handleMinMaxValue: (which: 'min' | 'max') => void applyInputValue: (displayValue: string) => void formatValue: (value: number | undefined) => string getMin: () => number | undefined getMax: () => number | undefined isDisabled: () => boolean isReadonly: () => boolean getInputMode: () => 'decimal' | 'numeric' isWheelChangeDisabled: () => boolean isWheelChangeInverted: () => boolean getSize: () => NumberFieldSize getStatus: () => NumberFieldStatus | undefined getControlsPosition: () => NumberFieldControlsPosition isKeyboardEnabled: () => boolean getFormatter: () => ((value: number | undefined) => string) | undefined getParser: () => ((displayValue: string) => number | undefined) | undefined getPrefix: () => string | undefined getSuffix: () => string | undefined getId: () => string | undefined getPlaceholder: () => string | undefined } const NumberFieldContextKey = Symbol('NumberFieldContext') export function setNumberFieldContext(context: NumberFieldContextValue) { setContext(NumberFieldContextKey, context) } const fallbackContext: NumberFieldContextValue = { getValue: () => undefined, setValue: () => {}, handleIncrease: () => {}, handleDecrease: () => {}, handleMinMaxValue: () => {}, applyInputValue: () => {}, formatValue: (value) => (value === undefined ? '' : String(value)), getMin: () => undefined, getMax: () => undefined, isDisabled: () => false, isReadonly: () => false, getInputMode: () => 'decimal', isWheelChangeDisabled: () => false, isWheelChangeInverted: () => false, getSize: () => 'middle', getStatus: () => undefined, getControlsPosition: () => 'default', isKeyboardEnabled: () => true, getFormatter: () => undefined, getParser: () => undefined, getPrefix: () => undefined, getSuffix: () => undefined, getId: () => undefined, getPlaceholder: () => undefined, } export function getNumberFieldContext(): NumberFieldContextValue { return getContext<NumberFieldContextValue>(NumberFieldContextKey) ?? fallbackContext }src/lib/components/ui/number-field/NumberFieldDecrement.svelte1.9 kB
<script lang="ts" module> import type { HTMLButtonAttributes } from 'svelte/elements' export interface NumberFieldDecrementProps extends HTMLButtonAttributes { ref?: HTMLButtonElement | null } </script> <script lang="ts"> import { Minus } from '@lucide/svelte' import { cn } from '$lib/utils' import { getNumberFieldContext } from './NumberFieldContext' let { class: className, disabled, children, ref = $bindable(null), onclick, ...restProps }: NumberFieldDecrementProps = $props() const uiContext = getNumberFieldContext() const isRight = $derived(uiContext.getControlsPosition() === 'right') const size = $derived(uiContext.getSize()) const iconSize = $derived(size === 'small' ? 'h-3 w-3' : size === 'large' ? 'h-5 w-5' : 'h-4 w-4') const atBound = $derived.by(() => { const v = uiContext.getValue() const min = uiContext.getMin() return v !== undefined && min !== undefined && v <= min }) const isDisabled = $derived(Boolean(disabled) || uiContext.isDisabled() || uiContext.isReadonly() || atBound) </script> <button bind:this={ref} type="button" tabindex="-1" data-uipkge="" data-slot="decrement" aria-label="Decrease" disabled={isDisabled} class={cn( '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', !isRight && 'absolute top-1/2 left-0 z-10 -translate-y-1/2', !isRight && size === 'small' && 'p-1.5', !isRight && size === 'middle' && 'p-3', !isRight && size === 'large' && 'p-4', isRight && 'hover:bg-accent col-start-2 row-start-2 h-full w-auto rounded-none border-t border-l p-0 px-2', className, )} onclick={(e) => { uiContext.handleDecrease() onclick?.(e) }} {...restProps} > {#if children} {@render children()} {:else} <Minus class={iconSize} aria-hidden="true" /> {/if} </button>src/lib/components/ui/number-field/NumberFieldIncrement.svelte1.9 kB
<script lang="ts" module> import type { HTMLButtonAttributes } from 'svelte/elements' export interface NumberFieldIncrementProps extends HTMLButtonAttributes { ref?: HTMLButtonElement | null } </script> <script lang="ts"> import { Plus } from '@lucide/svelte' import { cn } from '$lib/utils' import { getNumberFieldContext } from './NumberFieldContext' let { class: className, disabled, children, ref = $bindable(null), onclick, ...restProps }: NumberFieldIncrementProps = $props() const uiContext = getNumberFieldContext() const isRight = $derived(uiContext.getControlsPosition() === 'right') const size = $derived(uiContext.getSize()) const iconSize = $derived(size === 'small' ? 'h-3 w-3' : size === 'large' ? 'h-5 w-5' : 'h-4 w-4') const atBound = $derived.by(() => { const v = uiContext.getValue() const max = uiContext.getMax() return v !== undefined && max !== undefined && v >= max }) const isDisabled = $derived(Boolean(disabled) || uiContext.isDisabled() || uiContext.isReadonly() || atBound) </script> <button bind:this={ref} type="button" tabindex="-1" data-uipkge="" data-slot="increment" aria-label="Increase" disabled={isDisabled} class={cn( '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', !isRight && 'absolute top-1/2 right-0 z-10 -translate-y-1/2', !isRight && size === 'small' && 'p-1.5', !isRight && size === 'middle' && 'p-3', !isRight && size === 'large' && 'p-4', isRight && 'hover:bg-accent col-start-2 row-start-1 h-full w-auto rounded-none border-l p-0 px-2', className, )} onclick={(e) => { uiContext.handleIncrease() onclick?.(e) }} {...restProps} > {#if children} {@render children()} {:else} <Plus class={iconSize} aria-hidden="true" /> {/if} </button>src/lib/components/ui/number-field/NumberFieldInput.svelte5.8 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface NumberFieldInputProps extends HTMLAttributes<HTMLDivElement> { ref?: HTMLInputElement | null } </script> <script lang="ts"> import { tick } from 'svelte' import { cn } from '$lib/utils' import { getNumberFieldContext } from './NumberFieldContext' let { class: className, ref = $bindable(null), ...restProps }: NumberFieldInputProps = $props() const rootContext = getNumberFieldContext() let inputEl: HTMLInputElement | null = $state(null) $effect(() => { ref = inputEl }) let displayValue = $state('') let isUserTyping = $state(false) function updateDisplayValue(val: number | undefined) { displayValue = rootContext.formatValue(val) } // Mirror the Vue watchers: external value changes repaint the input unless // the user is mid-edit. $effect(() => { const val = rootContext.getValue() rootContext.getFormatter() if (!isUserTyping) updateDisplayValue(val) }) function handleFocus() { isUserTyping = true } function commitValue() { isUserTyping = false const raw = displayValue.trim() if (raw === '') { rootContext.setValue(undefined) } else { const parser = rootContext.getParser() let num: number | undefined if (parser) { num = parser(raw) } else { num = Number(raw) } if (num !== undefined && !Number.isNaN(num)) { rootContext.applyInputValue(String(num)) } } tick().then(() => { updateDisplayValue(rootContext.getValue()) }) } function handleBlur() { commitValue() } function handleKeydown(event: KeyboardEvent) { if (event.key === 'ArrowUp') { if (rootContext.isKeyboardEnabled()) { event.preventDefault() rootContext.handleIncrease() } } else if (event.key === 'ArrowDown') { if (rootContext.isKeyboardEnabled()) { event.preventDefault() rootContext.handleDecrease() } } else if (event.key === 'PageUp') { if (rootContext.isKeyboardEnabled()) { event.preventDefault() rootContext.handleIncrease(10) } } else if (event.key === 'PageDown') { if (rootContext.isKeyboardEnabled()) { event.preventDefault() rootContext.handleDecrease(10) } } else if (event.key === 'Home') { if (rootContext.isKeyboardEnabled()) { event.preventDefault() rootContext.handleMinMaxValue('min') } } else if (event.key === 'End') { if (rootContext.isKeyboardEnabled()) { event.preventDefault() rootContext.handleMinMaxValue('max') } } else if (event.key === 'Enter') { commitValue() } } function handleWheel(event: WheelEvent) { if (rootContext.isWheelChangeDisabled()) return if (event.target !== document.activeElement) return if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return event.preventDefault() if (event.deltaY > 0) { rootContext.isWheelChangeInverted() ? rootContext.handleDecrease() : rootContext.handleIncrease() } else { rootContext.isWheelChangeInverted() ? rootContext.handleIncrease() : rootContext.handleDecrease() } } const isRight = $derived(rootContext.getControlsPosition() === 'right') const status = $derived(rootContext.getStatus()) const prefix = $derived(rootContext.getPrefix()) const suffix = $derived(rootContext.getSuffix()) const sizeClasses = $derived.by(() => { switch (rootContext.getSize()) { case 'small': return 'h-7 text-xs px-2 py-0.5' case 'large': return 'h-11 text-base px-4 py-2' default: return 'h-9 text-sm px-3 py-1' } }) const statusClasses = $derived( status === 'error' ? 'border-destructive focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 aria-invalid:border-destructive' : status === 'warning' ? 'border-warning focus-visible:ring-warning/20' : '', ) const modelValue = $derived(rootContext.getValue()) </script> <div data-uipkge="" data-slot="input" class={cn('relative flex-1', isRight && 'col-span-1 row-span-2', className)}> {#if prefix} <span class="text-muted-foreground pointer-events-none absolute top-1/2 left-2 -translate-y-1/2 text-sm"> {prefix} </span> {/if} <input bind:this={inputEl} id={rootContext.getId()} value={displayValue} type="text" role="spinbutton" aria-valuenow={modelValue !== undefined && !Number.isNaN(modelValue) ? modelValue : undefined} aria-valuemin={rootContext.getMin()} aria-valuemax={rootContext.getMax()} inputmode={rootContext.getInputMode()} disabled={rootContext.isDisabled()} readonly={rootContext.isReadonly()} placeholder={rootContext.getPlaceholder()} aria-invalid={status === 'error' ? true : undefined} autocomplete="off" autocorrect="off" spellcheck="false" aria-roledescription="Number field" oninput={(e) => { displayValue = (e.target as HTMLInputElement).value }} onfocus={handleFocus} onblur={handleBlur} onkeydown={handleKeydown} onwheel={handleWheel} class={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', sizeClasses, !isRight && statusClasses, )} {...restProps} /> {#if suffix} <span class="text-muted-foreground pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 text-sm"> {suffix} </span> {/if} </div>src/lib/components/ui/number-field/index.ts0.7 kB
export { default as NumberField, type NumberFieldProps } from './NumberField.svelte' export { default as NumberFieldContent, type NumberFieldContentProps } from './NumberFieldContent.svelte' export { default as NumberFieldDecrement, type NumberFieldDecrementProps } from './NumberFieldDecrement.svelte' export { default as NumberFieldIncrement, type NumberFieldIncrementProps } from './NumberFieldIncrement.svelte' export { default as NumberFieldInput, type NumberFieldInputProps } from './NumberFieldInput.svelte' export { getNumberFieldContext, setNumberFieldContext } from './NumberFieldContext' export type { NumberFieldContextValue, NumberFieldControlsPosition, NumberFieldSize, NumberFieldStatus, } from './NumberFieldContext'
Raw manifest:https://uipkge.dev/r/svelte/number-field.json