Number Field
Vue 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 React ->Installation
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/number-field.json$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/number-field.json$ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/number-field.json$ bunx shadcn-vue@latest add https://uipkge.dev/r/vue/number-field.json
Or with the named registry:
npx shadcn-vue@latest add @uipkge/number-field
Examples
Schema
Type aliases from this item's source — use them to shape the data you pass in.
NumberFieldContext interface NumberFieldContext {
size: Ref<NumberFieldSize>
status: Ref<NumberFieldStatus | undefined>
controlsPosition: Ref<NumberFieldControlsPosition>
keyboard: Ref<boolean>
formatter: Ref<((value: number | undefined) => string) | undefined>
parser: Ref<((displayValue: string) => number | undefined) | undefined>
prefix: Ref<string | undefined>
suffix: Ref<string | undefined>
} Dependencies
Files (7)
-
app/components/ui/number-field/NumberField.vue 2.6 kB
<script setup lang="ts"> import type { NumberFieldRootEmits, NumberFieldRootProps } from 'reka-ui' import type { HTMLAttributes } from 'vue' import { computed } from 'vue' import { reactiveOmit } from '@vueuse/core' import { NumberFieldRoot, useForwardPropsEmits } from 'reka-ui' import { cn } from '@/lib/utils' import { provideNumberFieldContext } from './NumberFieldContext' export type NumberFieldSize = 'small' | 'middle' | 'large' export type NumberFieldStatus = 'error' | 'warning' export type NumberFieldControlsPosition = 'default' | 'right' // Use intersection in defineProps<> instead of `interface Props extends`: // Vue 3.5+'s SFC compiler has its own mini-resolver for the `extends` // clause that bails when the external package's package.json lacks // `exports.types` (true for reka-ui). The defineProps<T & U>() path // delegates type extraction to the project's installed `typescript` // package, which is shipped by init.json -- this works reliably. interface ExtraProps { class?: HTMLAttributes['class'] size?: NumberFieldSize status?: NumberFieldStatus controlsPosition?: NumberFieldControlsPosition keyboard?: boolean precision?: number formatter?: (value: number | undefined) => string parser?: (displayValue: string) => number | undefined prefix?: string suffix?: string } const props = withDefaults(defineProps<NumberFieldRootProps & ExtraProps>(), { step: 1, keyboard: true, controlsPosition: 'default', }) const emits = defineEmits<NumberFieldRootEmits>() const delegatedProps = reactiveOmit( props, 'class', 'size', 'status', 'controlsPosition', 'keyboard', 'precision', 'formatter', 'parser', 'prefix', 'suffix', 'formatOptions', ) const formatOptions = computed(() => { if (props.precision !== undefined) { return { ...props.formatOptions, minimumFractionDigits: props.precision, maximumFractionDigits: props.precision, } } return props.formatOptions }) const forwarded = useForwardPropsEmits(delegatedProps, emits) provideNumberFieldContext({ size: computed(() => props.size ?? 'middle'), status: computed(() => props.status), controlsPosition: computed(() => props.controlsPosition), keyboard: computed(() => props.keyboard), formatter: computed(() => props.formatter), parser: computed(() => props.parser), prefix: computed(() => props.prefix), suffix: computed(() => props.suffix), }) </script> <template> <NumberFieldRoot v-slot="slotProps" v-bind="forwarded" :format-options="formatOptions" :class="cn('inline-flex', props.class)" > <slot v-bind="slotProps" /> </NumberFieldRoot> </template> -
app/components/ui/number-field/NumberFieldContent.vue 0.8 kB
<script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { computed } from 'vue' import { cn } from '@/lib/utils' import { injectNumberFieldContext } from './NumberFieldContext' const props = defineProps<{ class?: HTMLAttributes['class'] }>() const uiContext = injectNumberFieldContext() const isRight = computed(() => uiContext.controlsPosition.value === 'right') </script> <template> <div :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-5 [&>[data-slot=input]]:has-[[data-slot=increment]]:pr-5', props.class, ) " > <slot /> </div> </template> -
app/components/ui/number-field/NumberFieldContext.ts 1.2 kB
import type { InjectionKey, Ref } from 'vue' import { inject, provide, ref } from 'vue' export type NumberFieldSize = 'small' | 'middle' | 'large' export type NumberFieldStatus = 'error' | 'warning' export type NumberFieldControlsPosition = 'default' | 'right' export interface NumberFieldContext { size: Ref<NumberFieldSize> status: Ref<NumberFieldStatus | undefined> controlsPosition: Ref<NumberFieldControlsPosition> keyboard: Ref<boolean> formatter: Ref<((value: number | undefined) => string) | undefined> parser: Ref<((displayValue: string) => number | undefined) | undefined> prefix: Ref<string | undefined> suffix: Ref<string | undefined> } export const NumberFieldContextKey: InjectionKey<NumberFieldContext> = Symbol('NumberFieldContext') export function provideNumberFieldContext(context: NumberFieldContext) { provide(NumberFieldContextKey, context) } export function injectNumberFieldContext(): NumberFieldContext { return inject(NumberFieldContextKey, { size: ref('middle'), status: ref(undefined), controlsPosition: ref('default'), keyboard: ref(true), formatter: ref(undefined), parser: ref(undefined), prefix: ref(undefined), suffix: ref(undefined), }) } -
app/components/ui/number-field/NumberFieldDecrement.vue 1.7 kB
<script setup lang="ts"> import type { NumberFieldDecrementProps } from 'reka-ui' import type { HTMLAttributes } from 'vue' import { computed } from 'vue' import { reactiveOmit } from '@vueuse/core' import { Minus } from 'lucide-vue-next' import { NumberFieldDecrement, useForwardProps } from 'reka-ui' import { cn } from '@/lib/utils' import { injectNumberFieldContext } from './NumberFieldContext' const props = defineProps<NumberFieldDecrementProps & { class?: HTMLAttributes['class'] }>() const delegatedProps = reactiveOmit(props, 'class') const forwarded = useForwardProps(delegatedProps) const uiContext = injectNumberFieldContext() const isRight = computed(() => uiContext.controlsPosition.value === 'right') const iconSize = computed(() => { switch (uiContext.size.value) { case 'small': return 'h-3 w-3' case 'large': return 'h-5 w-5' default: return 'h-4 w-4' } }) </script> <template> <NumberFieldDecrement data-uipkge data-slot="decrement" v-bind="forwarded" :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 -translate-y-1/2', !isRight && uiContext.size.value === 'small' && 'p-1.5', !isRight && uiContext.size.value === 'middle' && 'p-3', !isRight && uiContext.size.value === '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', props.class, ) " > <slot> <Minus :class="iconSize" aria-hidden="true" /> </slot> </NumberFieldDecrement> </template> -
app/components/ui/number-field/NumberFieldIncrement.vue 1.7 kB
<script setup lang="ts"> import type { NumberFieldIncrementProps } from 'reka-ui' import type { HTMLAttributes } from 'vue' import { computed } from 'vue' import { reactiveOmit } from '@vueuse/core' import { Plus } from 'lucide-vue-next' import { NumberFieldIncrement, useForwardProps } from 'reka-ui' import { cn } from '@/lib/utils' import { injectNumberFieldContext } from './NumberFieldContext' const props = defineProps<NumberFieldIncrementProps & { class?: HTMLAttributes['class'] }>() const delegatedProps = reactiveOmit(props, 'class') const forwarded = useForwardProps(delegatedProps) const uiContext = injectNumberFieldContext() const isRight = computed(() => uiContext.controlsPosition.value === 'right') const iconSize = computed(() => { switch (uiContext.size.value) { case 'small': return 'h-3 w-3' case 'large': return 'h-5 w-5' default: return 'h-4 w-4' } }) </script> <template> <NumberFieldIncrement data-uipkge data-slot="increment" v-bind="forwarded" :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 -translate-y-1/2', !isRight && uiContext.size.value === 'small' && 'p-1.5', !isRight && uiContext.size.value === 'middle' && 'p-3', !isRight && uiContext.size.value === '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', props.class, ) " > <slot> <Plus :class="iconSize" aria-hidden="true" /> </slot> </NumberFieldIncrement> </template> -
app/components/ui/number-field/NumberFieldInput.vue 5.8 kB
<script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { computed, nextTick, onMounted, ref, watch } from 'vue' import { injectNumberFieldRootContext } from 'reka-ui' import { cn } from '@/lib/utils' import { injectNumberFieldContext } from './NumberFieldContext' const props = defineProps<{ class?: HTMLAttributes['class'] }>() const rootContext = injectNumberFieldRootContext() const uiContext = injectNumberFieldContext() const inputRef = ref<HTMLInputElement>() onMounted(() => { if (inputRef.value) { rootContext.onInputElement(inputRef.value) } }) const displayValue = ref('') const isUserTyping = ref(false) function updateDisplayValue(val: number | undefined) { const formatter = uiContext.formatter.value if (formatter) { displayValue.value = formatter(val) } else { displayValue.value = rootContext.textValue.value } } watch( () => rootContext.modelValue.value, (val) => { if (!isUserTyping.value) { updateDisplayValue(val) } }, { immediate: true }, ) watch( () => rootContext.textValue.value, (val) => { if (!isUserTyping.value && !uiContext.formatter.value) { displayValue.value = val } }, ) function handleFocus() { isUserTyping.value = true } function handleInput(event: Event) { displayValue.value = (event.target as HTMLInputElement).value } function commitValue() { isUserTyping.value = false const raw = displayValue.value.trim() if (raw === '') { rootContext.modelValue.value = undefined } else { const parser = uiContext.parser.value let num: number | undefined if (parser) { num = parser(raw) } else { num = Number(raw) } if (num !== undefined && !Number.isNaN(num)) { rootContext.applyInputValue(String(num)) } } nextTick(() => { updateDisplayValue(rootContext.modelValue.value) }) } function handleBlur() { commitValue() } function handleKeydown(event: KeyboardEvent) { if (event.key === 'ArrowUp') { if (uiContext.keyboard.value) { event.preventDefault() rootContext.handleIncrease() } } else if (event.key === 'ArrowDown') { if (uiContext.keyboard.value) { event.preventDefault() rootContext.handleDecrease() } } else if (event.key === 'PageUp') { if (uiContext.keyboard.value) { event.preventDefault() rootContext.handleIncrease(10) } } else if (event.key === 'PageDown') { if (uiContext.keyboard.value) { event.preventDefault() rootContext.handleDecrease(10) } } else if (event.key === 'Home') { if (uiContext.keyboard.value) { event.preventDefault() rootContext.handleMinMaxValue('min') } } else if (event.key === 'End') { if (uiContext.keyboard.value) { event.preventDefault() rootContext.handleMinMaxValue('max') } } else if (event.key === 'Enter') { commitValue() } } function handleWheel(event: WheelEvent) { if (rootContext.disableWheelChange.value) return if (event.target !== document.activeElement) return if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return event.preventDefault() if (event.deltaY > 0) { rootContext.invertWheelChange.value ? rootContext.handleDecrease() : rootContext.handleIncrease() } else { rootContext.invertWheelChange.value ? rootContext.handleIncrease() : rootContext.handleDecrease() } } const isRight = computed(() => uiContext.controlsPosition.value === 'right') const sizeClasses = computed(() => { switch (uiContext.size.value) { 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 = computed(() => { switch (uiContext.status.value) { case 'error': return 'border-destructive focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 aria-invalid:border-destructive' case 'warning': return 'border-[var(--warning)] focus-visible:ring-[var(--warning)]/20' default: return '' } }) </script> <template> <div data-uipkge data-slot="input" :class="cn('relative flex-1', isRight && 'col-span-1 row-span-2', props.class)"> <span v-if="uiContext.prefix.value" class="text-muted-foreground pointer-events-none absolute top-1/2 left-2 -translate-y-1/2 text-sm" > {{ uiContext.prefix.value }} </span> <input ref="inputRef" v-model="displayValue" type="text" role="spinbutton" :aria-valuenow="rootContext.modelValue.value" :aria-valuemin="rootContext.min.value" :aria-valuemax="rootContext.max.value" :inputmode="rootContext.inputMode.value" :disabled="rootContext.disabled.value" :readonly="rootContext.readonly.value" :aria-invalid="uiContext.status.value === 'error' ? true : undefined" autocomplete="off" autocorrect="off" spellcheck="false" aria-roledescription="Number field" @focus="handleFocus" @input="handleInput" @blur="handleBlur" @keydown="handleKeydown" @wheel="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', uiContext.prefix.value && 'pl-6', uiContext.suffix.value && 'pr-6', sizeClasses, !isRight && statusClasses, ) " /> <span v-if="uiContext.suffix.value" class="text-muted-foreground pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 text-sm" > {{ uiContext.suffix.value }} </span> </div> </template> -
app/components/ui/number-field/index.ts 0.5 kB
export { default as NumberField } from './NumberField.vue' export { default as NumberFieldContent } from './NumberFieldContent.vue' export { default as NumberFieldDecrement } from './NumberFieldDecrement.vue' export { default as NumberFieldIncrement } from './NumberFieldIncrement.vue' export { default as NumberFieldInput } from './NumberFieldInput.vue' export { injectNumberFieldContext, provideNumberFieldContext } from './NumberFieldContext' export type { NumberFieldControlsPosition, NumberFieldSize, NumberFieldStatus } from './NumberField.vue'
Raw manifest: https://uipkge.dev/r/vue/number-field.json