UIPackage
Menu

Framework

Change language

Boilerplate repo

Pin Input

pin-input ui
Boilerplate repo

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
Named registry: npx shadcn-vue@latest add @uipkge/pin-input Installs to: app/components/ui/pin-input/

Examples

Loading interactive previews…

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>
}

Used by

Files installed (5)

  • app/components/ui/pin-input/PinInput.vue 3.1 kB
    <script setup lang="ts" generic="Type extends 'text' | 'number' = 'text'">
    import { nextTick, provide, ref, toRef, watch } 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)
      }
    }
    
    // One-shot shake when status transitions into error (not on mount / not continuous).
    const isShaking = ref(false)
    const isFirstStatus = ref(true)
    
    watch(
      () => props.status,
      (next, prev) => {
        // immediate:true so mount-time status="error" does not shake.
        if (isFirstStatus.value) {
          isFirstStatus.value = false
          return
        }
        if (next === 'error' && prev !== 'error') {
          isShaking.value = false
          nextTick(() => {
            isShaking.value = true
          })
        }
      },
      { immediate: true },
    )
    
    function onShakeEnd(event: AnimationEvent) {
      if (event.animationName === 'pin-input-shake') {
        isShaking.value = false
      }
    }
    </script>
    
    <template>
      <PinInputRoot
        :otp="props.otp"
        data-uipkge
        data-slot="pin-input"
        :data-status="props.status === 'default' ? undefined : props.status"
        v-bind="forwarded"
        :class="
          cn(
            'flex items-center gap-2 disabled:cursor-not-allowed has-disabled:opacity-50',
            isShaking && 'pin-input-shake',
            props.class,
          )
        "
        @complete="handleComplete"
        @animationend="onShakeEnd"
      >
        <slot />
      </PinInputRoot>
    </template>
    
    <style>
    @keyframes pin-input-shake {
      0%,
      100% {
        transform: translateX(0);
      }
      20% {
        transform: translateX(-5px);
      }
      40% {
        transform: translateX(5px);
      }
      60% {
        transform: translateX(-3px);
      }
      80% {
        transform: translateX(3px);
      }
    }
    
    [data-slot='pin-input'].pin-input-shake {
      animation: pin-input-shake 380ms cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
    }
    
    @media (prefers-reduced-motion: reduce) {
      [data-slot='pin-input'].pin-input-shake {
        animation: none !important;
      }
    }
    </style>
  • 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.5 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" role="separator" v-bind="forwardedProps">
        <slot>
          <Minus aria-hidden="true" />
        </slot>
      </Primitive>
    </template>
  • app/components/ui/pin-input/PinInputSlot.vue 4 kB
    <script setup lang="ts">
    import { computed, inject, nextTick, ref, watch } from 'vue'
    import type { PinInputInputProps } from 'reka-ui'
    import type { HTMLAttributes, Ref } from 'vue'
    import { reactiveOmit } from '@vueuse/core'
    import { injectPinInputRootContext, 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 ''
      }
    })
    
    // Quiet slot pop when a character lands (paste-safe via reka root context).
    const rekaRoot = injectPinInputRootContext()
    const slotValue = computed(() => rekaRoot.currentModelValue.value[props.index])
    const isPopping = ref(false)
    const isFirstValue = ref(true)
    const prevValue = ref<string | number | undefined | null>(undefined)
    
    watch(
      slotValue,
      (next) => {
        // immediate:true captures mount value (empty or prefilled) without animating.
        if (isFirstValue.value) {
          isFirstValue.value = false
          prevValue.value = next
          return
        }
        const filled = next !== undefined && next !== null && next !== ''
        if (filled && next !== prevValue.value) {
          // Restart animation even on rapid sequential digits.
          isPopping.value = false
          nextTick(() => {
            isPopping.value = true
          })
        }
        prevValue.value = next
      },
      { immediate: true },
    )
    
    function onPopEnd(event: AnimationEvent) {
      if (event.animationName === 'pin-slot-pop') {
        isPopping.value = false
      }
    }
    </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 outline-none first:ml-0 first:rounded-l-md last:rounded-r-md',
            'transition-[border-color,box-shadow,color,transform] duration-150 ease-out',
            'focus:border-ring focus:ring-ring/40 focus:relative focus:z-10 focus:ring-2',
            'disabled:cursor-not-allowed disabled:opacity-50',
            isPopping && 'pin-slot-pop',
            sizeClasses,
            statusClasses,
            props.class,
          )
        "
        @animationend="onPopEnd"
      />
    </template>
    
    <style>
    /* Quieter than payment-card char pop — whole slot, short overshoot. */
    @keyframes pin-slot-pop {
      0% {
        transform: scale(1);
      }
      40% {
        transform: scale(1.06);
      }
      100% {
        transform: scale(1);
      }
    }
    
    [data-slot='pin-input-slot'].pin-slot-pop {
      animation: pin-slot-pop 200ms cubic-bezier(0.22, 1.25, 0.36, 1) both;
      z-index: 1;
    }
    
    @media (prefers-reduced-motion: reduce) {
      [data-slot='pin-input-slot'].pin-slot-pop {
        animation: none !important;
      }
      [data-slot='pin-input-slot'] {
        transition-duration: 0ms !important;
      }
    }
    </style>
  • 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