UIPackage

Pin Input

Vue form
Edit on GitHub

One-time-code input — N separate boxes that auto-advance and accept paste. Use for SMS verification, 2FA, and short numeric codes. Length, masking, and per-slot status all configurable.

Also available for React ->

Installation

$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/pin-input.json

Or with the named registry: npx shadcn-vue@latest add @uipkge/pin-input

Examples

Props

Name Type / Values Default Required
class HTMLAttributes['class'] optional
mask boolean false optional
autoSubmit boolean false optional
status PinInputStatus 'default' optional
size PinInputSize 'md' optional

Schema

Type aliases from this item's source — use them to shape the data you pass in.

PinInputContext
interface PinInputContext {
  mask: Ref<boolean>
  status: Ref<PinInputStatus>
  size: Ref<PinInputSize>
}

Dependencies

Used by

Files (5)

  • app/components/ui/pin-input/PinInput.vue 1.7 kB
    <script setup lang="ts" generic="Type extends 'text' | 'number' = 'text'">
    import { computed, provide, toRef } from 'vue'
    import type { PinInputRootEmits, PinInputRootProps } from 'reka-ui'
    import type { HTMLAttributes } from 'vue'
    import { reactiveOmit } from '@vueuse/core'
    import { PinInputRoot, useForwardPropsEmits } from 'reka-ui'
    import { cn } from '@/lib/utils'
    
    type PinInputStatus = 'error' | 'warning' | 'success' | 'default'
    type PinInputSize = 'sm' | 'md' | 'lg'
    
    const props = withDefaults(
      defineProps<
        PinInputRootProps<Type> & {
          class?: HTMLAttributes['class']
          mask?: boolean
          autoSubmit?: boolean
          status?: PinInputStatus
          size?: PinInputSize
        }
      >(),
      {
        otp: true,
        mask: false,
        autoSubmit: false,
        status: 'default',
        size: 'md',
      },
    )
    
    const emits = defineEmits<
      Omit<PinInputRootEmits<Type>, 'complete'> & {
        complete: [value: string]
      }
    >()
    
    const delegatedProps = reactiveOmit(props, 'class', 'mask', 'autoSubmit', 'status', 'size')
    const forwarded = useForwardPropsEmits(delegatedProps, emits)
    
    provide('pinInputContext', {
      mask: toRef(props, 'mask'),
      status: toRef(props, 'status'),
      size: toRef(props, 'size'),
    })
    
    function handleComplete(value: string[]) {
      const joined = value.join('')
      emits('complete', joined)
      if (props.autoSubmit) {
        const event = new CustomEvent('pin-submit', { detail: joined, bubbles: true })
        document.dispatchEvent(event)
      }
    }
    </script>
    
    <template>
      <PinInputRoot
        :otp="props.otp"
        data-uipkge
        data-slot="pin-input"
        v-bind="forwarded"
        :class="cn('flex items-center gap-2 disabled:cursor-not-allowed has-disabled:opacity-50', props.class)"
        @complete="handleComplete"
      >
        <slot />
      </PinInputRoot>
    </template>
  • app/components/ui/pin-input/PinInputGroup.vue 0.6 kB
    <script setup lang="ts">
    import type { PrimitiveProps } from 'reka-ui'
    import type { HTMLAttributes } from 'vue'
    import { reactiveOmit } from '@vueuse/core'
    import { Primitive, useForwardProps } from 'reka-ui'
    import { cn } from '@/lib/utils'
    
    const props = defineProps<PrimitiveProps & { class?: HTMLAttributes['class'] }>()
    const delegatedProps = reactiveOmit(props, 'class')
    const forwardedProps = useForwardProps(delegatedProps)
    </script>
    
    <template>
      <Primitive
        data-uipkge
        data-slot="pin-input-group"
        v-bind="forwardedProps"
        :class="cn('flex items-center', props.class)"
      >
        <slot />
      </Primitive>
    </template>
  • app/components/ui/pin-input/PinInputSeparator.vue 0.4 kB
    <script setup lang="ts">
    import type { PrimitiveProps } from 'reka-ui'
    import { Minus } from 'lucide-vue-next'
    import { Primitive, useForwardProps } from 'reka-ui'
    
    const props = defineProps<PrimitiveProps>()
    const forwardedProps = useForwardProps(props)
    </script>
    
    <template>
      <Primitive data-uipkge data-slot="pin-input-separator" v-bind="forwardedProps">
        <slot>
          <Minus />
        </slot>
      </Primitive>
    </template>
  • app/components/ui/pin-input/PinInputSlot.vue 2.3 kB
    <script setup lang="ts">
    import { computed, inject } from 'vue'
    import type { PinInputInputProps } from 'reka-ui'
    import type { HTMLAttributes, Ref } from 'vue'
    import { reactiveOmit } from '@vueuse/core'
    import { PinInputInput, useForwardProps } from 'reka-ui'
    import { cn } from '@/lib/utils'
    
    type PinInputStatus = 'error' | 'warning' | 'success' | 'default'
    type PinInputSize = 'sm' | 'md' | 'lg'
    
    interface PinInputContext {
      mask: Ref<boolean>
      status: Ref<PinInputStatus>
      size: Ref<PinInputSize>
    }
    
    const props = defineProps<
      PinInputInputProps & {
        class?: HTMLAttributes['class']
        /** Override the inherited mask flag for this slot. */
        mask?: boolean
      }
    >()
    
    const ctx = inject<PinInputContext | null>('pinInputContext', null)
    const forwarded = useForwardProps(reactiveOmit(props, 'class', 'mask'))
    
    const effectiveMask = computed(() => props.mask ?? ctx?.mask.value ?? false)
    const status = computed(() => ctx?.status.value ?? 'default')
    const size = computed(() => ctx?.size.value ?? 'md')
    
    const inputType = computed(() => (effectiveMask.value ? 'password' : 'text'))
    
    const sizeClasses = computed(() => {
      switch (size.value) {
        case 'sm':
          return 'h-8 w-8 text-sm'
        case 'lg':
          return 'h-12 w-12 text-xl'
        default:
          return 'h-10 w-10 text-base'
      }
    })
    
    const statusClasses = computed(() => {
      switch (status.value) {
        case 'error':
          return 'border-destructive focus:border-destructive focus:ring-destructive/40 text-destructive'
        case 'warning':
          return 'border-warning focus:border-warning focus:ring-warning/40 text-warning'
        case 'success':
          return 'border-success focus:border-success focus:ring-success/40 text-success'
        default:
          return ''
      }
    })
    </script>
    
    <template>
      <PinInputInput
        :type="inputType"
        data-uipkge
        data-slot="pin-input-slot"
        v-bind="forwarded"
        :class="
          cn(
            'border-input bg-background text-foreground relative -ml-px flex items-center justify-center border text-center shadow-xs transition-[border-color,box-shadow] outline-none first:ml-0 first:rounded-l-md last:rounded-r-md',
            'focus:border-ring focus:ring-ring/40 focus:relative focus:z-10 focus:ring-2',
            'disabled:cursor-not-allowed disabled:opacity-50',
            sizeClasses,
            statusClasses,
            props.class,
          )
        "
      />
    </template>
  • app/components/ui/pin-input/index.ts 0.2 kB
    export { default as PinInput } from './PinInput.vue'
    export { default as PinInputGroup } from './PinInputGroup.vue'
    export { default as PinInputSeparator } from './PinInputSeparator.vue'
    export { default as PinInputSlot } from './PinInputSlot.vue'

Raw manifest: https://uipkge.dev/r/vue/pin-input.json