OTP Input Component

Features Customizable OTP length (length prop)

Fully controlled input via v-model (modelValue)

Auto-focus on next/previous inputs

Smart paste support

Only numeric input allowed

Focus styling and input clearing with backspace

<template>
    <div class="otp-input-container">
      <div v-for="(digit, index) in otpArray" :key="index" class="otp-digit-container">
        <input
          ref="otpInputs"
          type="tel"
          maxlength="1"
          pattern="[0-9]*"
          inputmode="numeric"
          v-model="otpArray[index]"
          class="otp-digit-input"
          :data-index="index"
          placeholder="-"
          @input="handleInput(index)"
          @keydown="handleKeyDown($event, index)"
          @paste="handlePaste"
          @focus="handleFocus"
        />
      </div>
    </div>
  </template>
  
  <script setup>
  import { ref, computed, watch, onMounted } from 'vue';
  
  const props = defineProps({
    length: {
      type: Number,
      default: 4
    },
    modelValue: {
      type: String,
      default: ''
    }
  });
  
  const emit = defineEmits(['update:modelValue']);
  
  const otpInputs = ref([]);
  const otpArray = ref(Array(props.length).fill(''));
  
  // Watch for external modelValue changes
  watch(() => props.modelValue, (newValue) => {
    if (newValue) {
      const valueArray = newValue.toString().split('');
      otpArray.value = Array(props.length).fill('').map((_, i) => valueArray[i] || '');
    } else {
      otpArray.value = Array(props.length).fill('');
    }
  }, { immediate: true });
  
  // Watch internal array for changes to emit
  watch(otpArray, (newArray) => {
    const joinedValue = newArray.join('');
    emit('update:modelValue', joinedValue);
  }, { deep: true });
  
  // Computed property to check if OTP is completed
  const isCompleted = computed(() => {
    return otpArray.value.every(digit => digit !== '') && otpArray.value.length === props.length;
  });
  
  // Handle input in a field
  const handleInput = (index) => {
    // Only allow numbers
    otpArray.value[index] = otpArray.value[index].replace(/[^0-9]/g, '');
    
    // Move to next input if current one has a value
    if (otpArray.value[index] !== '' && index < props.length - 1) {
      otpInputs.value[index + 1].focus();
    }
  };
  
  // Handle keydown events
  const handleKeyDown = (event, index) => {
    // Handle backspace
    if (event.key === 'Backspace') {
      if (otpArray.value[index] === '') {
        // Current field is empty, move to previous field and select its content
        if (index > 0) {
          event.preventDefault();
          otpInputs.value[index - 1].focus();
          otpInputs.value[index - 1].select();
        }
      }
    } 
    // Handle left arrow key
    else if (event.key === 'ArrowLeft') {
      if (index > 0) {
        event.preventDefault();
        otpInputs.value[index - 1].focus();
        otpInputs.value[index - 1].select();
      }
    } 
    // Handle right arrow key
    else if (event.key === 'ArrowRight') {
      if (index < props.length - 1) {
        event.preventDefault();
        otpInputs.value[index + 1].focus();
        otpInputs.value[index + 1].select();
      }
    }
  };
  
  // Handle paste event
  const handlePaste = (event) => {
    event.preventDefault();
    const pastedData = event.clipboardData.getData('text');
    const pastedDigits = pastedData.replace(/\D/g, '').split('');
    
    for (let i = 0; i < props.length && i < pastedDigits.length; i++) {
      otpArray.value[i] = pastedDigits[i];
    }
    
    // Focus on the next empty input or the last one
    const nextEmptyIndex = otpArray.value.findIndex(digit => digit === '');
    if (nextEmptyIndex !== -1) {
      otpInputs.value[nextEmptyIndex].focus();
    } else {
      otpInputs.value[props.length - 1].focus();
    }
  };
  
  // Handle focus event
  const handleFocus = (event) => {
    // Select the content when focused
    event.target.select();
  };
  
  // Focus on first input when mounted
  onMounted(() => {
    if (otpInputs.value.length && otpArray.value[0] === '') {
      otpInputs.value[0].focus();
    }
  });
  </script>
.otp-input-container {
  @apply flex items-center justify-between sm:gap-4 gap-2;
}
  
  .otp-digit-container {
    flex: 1;
  }
  
  .otp-digit-input {
    width: 100%;
    height: 40px;
    text-align: center;
    font-size: 24px;
    border: 1px solid #E1E1E1;
    border-radius: 8px;
    background-color: transparent;
    -moz-appearance: textfield;
    box-sizing: border-box;
  }
  
  .otp-digit-input:focus {
    border-color: #EBBC24;
    outline: none;
  }
  
  .otp-digit-input::-webkit-inner-spin-button,
  .otp-digit-input::-webkit-outer-spin-button {
    -webkit-appearance: none;
    margin: 0;
  }
Usage
    <div class="card gap-4 w-full mb-8">
      <OtpInput v-model="otpCode" :length="4" />
    </div>