Skip to content

InputNumber

Input numerical values with a customizable range.

Demo

This requires the following components to be installed:

This requires the following composables to be installed:

This requires the following theme to be installed:

Component

InputNumber.vue
vue
<script lang="ts">
import type { ButtonProps } from '@/ui/components/Button.vue'
import type { NumberFieldRootProps } from 'reka-ui'
import type { VariantProps } from 'tailwind-variants'
import Button from '@/ui/components/Button.vue'
import { useFormField } from '@/ui/composables/useFormField'
import { chevronDownIcon, chevronUpIcon, minusIcon, plusIcon } from '@/ui/icons'
import theme from '@/ui/theme/input-number'
import { reactivePick } from '@vueuse/shared'
import { NumberFieldDecrement, NumberFieldIncrement, NumberFieldInput, NumberFieldRoot, useForwardPropsEmits } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, onMounted, ref } from 'vue'

const inputNumber = tv(theme)

type InputNumberVariants = VariantProps<typeof inputNumber>

export interface InputNumberProps extends Pick<NumberFieldRootProps, 'modelValue' | 'defaultValue' | 'min' | 'max' | 'step' | 'disabled' | 'required' | 'id' | 'name' | 'formatOptions'> {
  as?: any
  placeholder?: string
  color?: InputNumberVariants['color']
  variant?: InputNumberVariants['variant']
  size?: InputNumberVariants['size']
  highlight?: boolean
  orientation?: 'vertical' | 'horizontal'
  increment?: ButtonProps
  decrement?: ButtonProps
  autofocus?: boolean
  autofocusDelay?: number
  locale?: string
  class?: any
  ui?: Partial<typeof inputNumber.slots>
}

export interface InputNumberEmits {
  (e: 'update:modelValue', payload: number): void
  (e: 'blur', event: FocusEvent): void
  (e: 'change', payload: Event): void
}

export interface InputNumberSlots {
  increment: (props?: object) => any
  decrement: (props?: object) => any
}
</script>

<script setup lang="ts">
defineOptions({ inheritAttrs: false })

const props = withDefaults(defineProps<InputNumberProps>(), {
  orientation: 'horizontal',
})
const emits = defineEmits<InputNumberEmits>()
defineSlots<InputNumberSlots>()

const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'min', 'max', 'step', 'formatOptions'), emits)

const { id, color, size, name, highlight, disabled, ariaAttrs } = useFormField<InputNumberProps>(props)

const locale = computed(() => props.locale)

const ui = computed(() => inputNumber({
  color: color.value,
  variant: props.variant,
  size: size.value,
  highlight: highlight.value,
  orientation: props.orientation,
}))

const incrementIcon = computed(() => (props.orientation === 'horizontal' ? plusIcon : chevronUpIcon))
const decrementIcon = computed(() => (props.orientation === 'horizontal' ? minusIcon : chevronDownIcon))

const inputRef = ref<InstanceType<typeof NumberFieldInput> | null>(null)

function autoFocus() {
  if (props.autofocus) {
    inputRef.value?.$el?.focus()
  }
}

onMounted(() => {
  setTimeout(() => {
    autoFocus()
  }, props.autofocusDelay)
})

function onUpdate(value: number) {
  // @ts-expect-error - 'target' does not exist in type 'EventInit'
  const event = new Event('change', { target: { value } })
  emits('change', event)
}

function onBlur(event: FocusEvent) {
  emits('blur', event)
}

defineExpose({
  inputRef,
})
</script>

<template>
  <NumberFieldRoot
    v-bind="rootProps"
    :id="id"
    :class="ui.root({ class: [props.class, props.ui?.root] })"
    :name="name"
    :disabled="disabled"
    :locale="locale"
    @update:model-value="onUpdate"
  >
    <NumberFieldInput
      v-bind="{ ...$attrs, ...ariaAttrs }"
      ref="inputRef"
      :placeholder="placeholder"
      :required="required"
      :class="ui.base({ class: props.ui?.base })"
      @blur="onBlur"
    />

    <div :class="ui.increment({ class: props.ui?.increment })">
      <NumberFieldIncrement as-child :disabled="disabled">
        <slot name="increment">
          <Button
            :icon="incrementIcon"
            :color="color"
            :size="size"
            variant="link"
            aria-label="Increment"
            v-bind="typeof increment === 'object' ? increment : undefined"
          />
        </slot>
      </NumberFieldIncrement>
    </div>

    <div :class="ui.decrement({ class: props.ui?.decrement })">
      <NumberFieldDecrement as-child :disabled="disabled">
        <slot name="decrement">
          <Button
            :icon="decrementIcon"
            :color="color"
            :size="size"
            variant="link"
            aria-label="Decrement"
            v-bind="typeof decrement === 'object' ? decrement : undefined"
          />
        </slot>
      </NumberFieldDecrement>
    </div>
  </NumberFieldRoot>
</template>
InputNumber.vue
vue
<script lang="ts">
import type { ButtonProps } from '@/UI/Components/Button.vue'
import type { NumberFieldRootProps } from 'reka-ui'
import type { VariantProps } from 'tailwind-variants'
import Button from '@/UI/Components/Button.vue'
import { useFormField } from '@/UI/Composables/useFormField'
import { chevronDownIcon, chevronUpIcon, minusIcon, plusIcon } from '@/UI/icons'
import theme from '@/UI/Theme/input-number'
import { reactivePick } from '@vueuse/shared'
import { NumberFieldDecrement, NumberFieldIncrement, NumberFieldInput, NumberFieldRoot, useForwardPropsEmits } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, onMounted, ref } from 'vue'

const inputNumber = tv(theme)

type InputNumberVariants = VariantProps<typeof inputNumber>

export interface InputNumberProps extends Pick<NumberFieldRootProps, 'modelValue' | 'defaultValue' | 'min' | 'max' | 'step' | 'disabled' | 'required' | 'id' | 'name' | 'formatOptions'> {
  as?: any
  placeholder?: string
  color?: InputNumberVariants['color']
  variant?: InputNumberVariants['variant']
  size?: InputNumberVariants['size']
  highlight?: boolean
  orientation?: 'vertical' | 'horizontal'
  increment?: ButtonProps
  decrement?: ButtonProps
  autofocus?: boolean
  autofocusDelay?: number
  locale?: string
  class?: any
  ui?: Partial<typeof inputNumber.slots>
}

export interface InputNumberEmits {
  (e: 'update:modelValue', payload: number): void
  (e: 'blur', event: FocusEvent): void
  (e: 'change', payload: Event): void
}

export interface InputNumberSlots {
  increment: (props?: object) => any
  decrement: (props?: object) => any
}
</script>

<script setup lang="ts">
defineOptions({ inheritAttrs: false })

const props = withDefaults(defineProps<InputNumberProps>(), {
  orientation: 'horizontal',
})
const emits = defineEmits<InputNumberEmits>()
defineSlots<InputNumberSlots>()

const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'min', 'max', 'step', 'formatOptions'), emits)

const { id, color, size, name, highlight, disabled, ariaAttrs } = useFormField<InputNumberProps>(props)

const locale = computed(() => props.locale)

const ui = computed(() => inputNumber({
  color: color.value,
  variant: props.variant,
  size: size.value,
  highlight: highlight.value,
  orientation: props.orientation,
}))

const incrementIcon = computed(() => (props.orientation === 'horizontal' ? plusIcon : chevronUpIcon))
const decrementIcon = computed(() => (props.orientation === 'horizontal' ? minusIcon : chevronDownIcon))

const inputRef = ref<InstanceType<typeof NumberFieldInput> | null>(null)

function autoFocus() {
  if (props.autofocus) {
    inputRef.value?.$el?.focus()
  }
}

onMounted(() => {
  setTimeout(() => {
    autoFocus()
  }, props.autofocusDelay)
})

function onUpdate(value: number) {
  // @ts-expect-error - 'target' does not exist in type 'EventInit'
  const event = new Event('change', { target: { value } })
  emits('change', event)
}

function onBlur(event: FocusEvent) {
  emits('blur', event)
}

defineExpose({
  inputRef,
})
</script>

<template>
  <NumberFieldRoot
    v-bind="rootProps"
    :id="id"
    :class="ui.root({ class: [props.class, props.ui?.root] })"
    :name="name"
    :disabled="disabled"
    :locale="locale"
    @update:model-value="onUpdate"
  >
    <NumberFieldInput
      v-bind="{ ...$attrs, ...ariaAttrs }"
      ref="inputRef"
      :placeholder="placeholder"
      :required="required"
      :class="ui.base({ class: props.ui?.base })"
      @blur="onBlur"
    />

    <div :class="ui.increment({ class: props.ui?.increment })">
      <NumberFieldIncrement as-child :disabled="disabled">
        <slot name="increment">
          <Button
            :icon="incrementIcon"
            :color="color"
            :size="size"
            variant="link"
            aria-label="Increment"
            v-bind="typeof increment === 'object' ? increment : undefined"
          />
        </slot>
      </NumberFieldIncrement>
    </div>

    <div :class="ui.decrement({ class: props.ui?.decrement })">
      <NumberFieldDecrement as-child :disabled="disabled">
        <slot name="decrement">
          <Button
            :icon="decrementIcon"
            :color="color"
            :size="size"
            variant="link"
            aria-label="Decrement"
            v-bind="typeof decrement === 'object' ? decrement : undefined"
          />
        </slot>
      </NumberFieldDecrement>
    </div>
  </NumberFieldRoot>
</template>

Theme

input-number.ts
ts
export default {
  slots: {
    root: '',
    base: '',
    increment: '',
    decrement: '',
  },
  variants: {
    color: {
      primary: '',
      secondary: '',
      success: '',
      info: '',
      warning: '',
      error: '',
      neutral: '',
    },
    size: {
      xs: '',
      sm: '',
      md: '',
      lg: '',
      xl: '',
    },
    variant: {
      outline: '',
      soft: '',
      subtle: '',
      ghost: '',
      none: '',
    },
    disabled: {
      true: {
        increment: '',
        decrement: '',
      },
    },
    orientation: {
      horizontal: {
        base: '',
        increment: '',
        decrement: '',
      },
      vertical: {
        increment: '',
        decrement: '',
      },
    },
    highlight: {
      true: '',
    },
  },
  compoundVariants: [],
  defaultVariants: {
    size: 'md',
    color: 'primary',
    variant: 'outline',
  } as const,
}
View Nuxt UI theme
input-number.ts
ts
export default {
  slots: {
    root: 'relative inline-flex items-center',
    base: 'w-full rounded-md border-0 placeholder:text-dimmed focus:outline-none disabled:cursor-not-allowed disabled:opacity-75 transition-colors',
    increment: 'absolute flex items-center',
    decrement: 'absolute flex items-center',
  },
  variants: {
    color: {
      primary: '',
      secondary: '',
      success: '',
      info: '',
      warning: '',
      error: '',
      neutral: '',
    },
    size: {
      xs: 'px-2 py-1 text-xs gap-1',
      sm: 'px-2.5 py-1.5 text-xs gap-1.5',
      md: 'px-2.5 py-1.5 text-sm gap-1.5',
      lg: 'px-3 py-2 text-sm gap-2',
      xl: 'px-3 py-2 text-base gap-2',
    },
    variant: {
      outline: 'text-highlighted bg-default ring ring-inset ring-accented',
      soft: 'text-highlighted bg-elevated/50 hover:bg-elevated focus:bg-elevated disabled:bg-elevated/50',
      subtle: 'text-highlighted bg-elevated ring ring-inset ring-accented',
      ghost: 'text-highlighted bg-transparent hover:bg-elevated focus:bg-elevated disabled:bg-transparent dark:disabled:bg-transparent',
      none: 'text-highlighted bg-transparent',
    },
    disabled: {
      true: {
        increment: 'opacity-75 cursor-not-allowed',
        decrement: 'opacity-75 cursor-not-allowed',
      },
    },
    orientation: {
      horizontal: {
        base: 'text-center',
        increment: 'inset-y-0 end-0 pe-1',
        decrement: 'inset-y-0 start-0 ps-1',
      },
      vertical: {
        increment: 'top-0 end-0 pe-1 [&>button]:py-0 scale-80',
        decrement: 'bottom-0 end-0 pe-1 [&>button]:py-0 scale-80',
      },
    },
    highlight: {
      true: '',
    },
  },
  compoundVariants: [
    {
      color: 'primary' as const,
      variant: ['outline' as const, 'subtle' as const],
      class: 'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary',
    },
    {
      color: 'primary',
      highlight: true,
      class: 'ring ring-inset ring-primary',
    } as const,
    {
      color: 'secondary' as const,
      variant: ['outline' as const, 'subtle' as const],
      class: 'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-secondary',
    },
    {
      color: 'secondary',
      highlight: true,
      class: 'ring ring-inset ring-secondary',
    } as const,
    {
      color: 'success' as const,
      variant: ['outline' as const, 'subtle' as const],
      class: 'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-success',
    },
    {
      color: 'success',
      highlight: true,
      class: 'ring ring-inset ring-success',
    } as const,
    {
      color: 'info' as const,
      variant: ['outline' as const, 'subtle' as const],
      class: 'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-info',
    },
    {
      color: 'info',
      highlight: true,
      class: 'ring ring-inset ring-info',
    } as const,
    {
      color: 'warning' as const,
      variant: ['outline' as const, 'subtle' as const],
      class: 'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-warning',
    },
    {
      color: 'warning',
      highlight: true,
      class: 'ring ring-inset ring-warning',
    } as const,
    {
      color: 'error' as const,
      variant: ['outline' as const, 'subtle' as const],
      class: 'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-error',
    },
    {
      color: 'error',
      highlight: true,
      class: 'ring ring-inset ring-error',
    } as const,
    {
      color: 'neutral' as const,
      variant: ['outline' as const, 'subtle' as const],
      class: 'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-inverted',
    },
    {
      color: 'neutral',
      highlight: true,
      class: 'ring ring-inset ring-inverted',
    } as const,
    {
      orientation: 'horizontal',
      size: 'xs',
      class: 'px-7',
    } as const,
    {
      orientation: 'horizontal',
      size: 'sm',
      class: 'px-8',
    } as const,
    {
      orientation: 'horizontal',
      size: 'md',
      class: 'px-9',
    } as const,
    {
      orientation: 'horizontal',
      size: 'lg',
      class: 'px-10',
    } as const,
    {
      orientation: 'horizontal',
      size: 'xl',
      class: 'px-11',
    } as const,
    {
      orientation: 'vertical',
      size: 'xs',
      class: 'pe-7',
    } as const,
    {
      orientation: 'vertical',
      size: 'sm',
      class: 'pe-8',
    } as const,
    {
      orientation: 'vertical',
      size: 'md',
      class: 'pe-9',
    } as const,
    {
      orientation: 'vertical',
      size: 'lg',
      class: 'pe-10',
    } as const,
    {
      orientation: 'vertical',
      size: 'xl',
      class: 'pe-11',
    } as const,
  ],
  defaultVariants: {
    size: 'md',
    color: 'primary',
    variant: 'outline',
  } as const,
}

Test

To test this component, you can use the following test file:

InputNumber.test.ts
ts
import type { RenderOptions } from '@testing-library/vue'
import InputNumber from '@/ui/components/InputNumber.vue'
import theme from '@/ui/theme/input-number'
import { render } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'

describe('input', () => {
  const sizes = Object.keys(theme.variants.size) as any
  const variants = Object.keys(theme.variants.variant) as any

  it.each<[string, RenderOptions<typeof InputNumber>]>([
    // Props
    ['with name', { props: { name: 'name' } }],
    ['with placeholder', { props: { placeholder: 'Number...' } }],
    ['with disabled', { props: { disabled: true } }],
    ['with required', { props: { required: true } }],
    ['with orientation vertical', { props: { orientation: 'vertical' } }],
    ...sizes.map((size: string) => [`with size ${size}`, { props: { size } }]),
    ...variants.map((variant: string) => [`with primary variant ${variant}`, { props: { variant } }]),
    ...variants.map((variant: string) => [`with neutral variant ${variant}`, { props: { variant, color: 'neutral' } }]),
    ['with as', { props: { as: 'section' } }],
    ['with class', { props: { class: 'absolute' } }],
    ['with ui', { props: { ui: { base: 'rounded-full' } } }],
    // Slots
    ['with increment slot', { slots: { increment: () => '+' } }],
    ['with decrement slot', { slots: { decrement: () => '-' } }],
  ])(`renders %s correctly`, (name, options) => {
    const { html } = render(InputNumber, options)

    expect(html()).toMatchSnapshot()
  })
})
InputNumber.test.ts
ts
import type { RenderOptions } from '@testing-library/vue'
import InputNumber from '@/UI/Components/InputNumber.vue'
import theme from '@/UI/Theme/input-number'
import { render } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'

describe('input', () => {
  const sizes = Object.keys(theme.variants.size) as any
  const variants = Object.keys(theme.variants.variant) as any

  it.each<[string, RenderOptions<typeof InputNumber>]>([
    // Props
    ['with name', { props: { name: 'name' } }],
    ['with placeholder', { props: { placeholder: 'Number...' } }],
    ['with disabled', { props: { disabled: true } }],
    ['with required', { props: { required: true } }],
    ['with orientation vertical', { props: { orientation: 'vertical' } }],
    ...sizes.map((size: string) => [`with size ${size}`, { props: { size } }]),
    ...variants.map((variant: string) => [`with primary variant ${variant}`, { props: { variant } }]),
    ...variants.map((variant: string) => [`with neutral variant ${variant}`, { props: { variant, color: 'neutral' } }]),
    ['with as', { props: { as: 'section' } }],
    ['with class', { props: { class: 'absolute' } }],
    ['with ui', { props: { ui: { base: 'rounded-full' } } }],
    // Slots
    ['with increment slot', { slots: { increment: () => '+' } }],
    ['with decrement slot', { slots: { decrement: () => '-' } }],
  ])(`renders %s correctly`, (name, options) => {
    const { html } = render(InputNumber, options)

    expect(html()).toMatchSnapshot()
  })
})

Contributors

barbapapazes

Changelog

fbdc1 - feat: input number (#155) on 3/26/2025