Skip to content
On this page

OTP Component

First Pattern

Features

  • Multi type support
  • Debounce
  • autofocus support
  • Error, Disabled, Focus State
  • optional error, label, placeholder texts etc

Dependencies

  • "tailwindcss" - for styling
  • "vueuse" - for debounce (useDebounceFn)

Demo

Your OTP is: []

Joined Otp:

Code

vue
<script setup lang="ts">
import { ref, watchEffect, onMounted, onBeforeUnmount, computed } from "vue";
/**
 * The component returns an array of string.
 * To clear the input simply set array to an empty one
 */

interface Props {
  type?: string;
  placeholder?: string;
  disabled?: boolean;
  inputCount?: number;
  modelValue?: string[] | number[];
  autoFocus?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  type: "text",
  placeholder: "*",
  disabled: false,
  inputCount: 4,
  autoFocus: true,
});

const emit = defineEmits<{
  (event: "update:modelValue", value: Array<string> | Array<number>): void;
}>();

const inputRefsHolder = ref(new Array(props.inputCount).fill(""));
const inputRefsHolderCopy = ref(new Array(props.inputCount).fill(""));

const otpValue = computed(() => {
  return inputRefsHolderCopy.value.map((otp) => (otp == null ? "" : otp));
});

function focusNext(currentIndex: number) {
  for (let i = currentIndex + 1; i < inputRefsHolder.value.length; i++) {
    if (inputRefsHolder.value[i] !== "") {
      if (inputRefsHolder.value[i].disabled) {
        continue;
      }
      inputRefsHolder.value[i].focus();
      return;
    }
  }
  return;
}

function focusPrev(currentIndex: number) {
  for (let i = currentIndex - 1; i >= 0; i--) {
    if (inputRefsHolder.value[i] !== "") {
      if (inputRefsHolder.value[i].disabled) {
        continue;
      }
      inputRefsHolder.value[i].setSelectionRange(1, 2);
      inputRefsHolder.value[i].focus();
      return;
    }
  }
  return;
}

// handle Input
function handleInput(e: Event, index: number) {
  const targetValue = (e.target as HTMLInputElement).value;
  const temporalOtp = [...inputRefsHolderCopy.value];
  temporalOtp[index] = targetValue.substring(targetValue.length - 1);
  inputRefsHolderCopy.value = temporalOtp;
  emit("update:modelValue", otpValue.value);
}

// handle key up
function handleKeyUp(e: KeyboardEvent, index: number) {
  if (inputRefsHolder.value[index].value) {
    focusNext(index);
  }
  if (e.key === "ArrowRight") {
    focusNext(index);
  }
  if (e.key === "ArrowLeft") {
    focusPrev(index);
  }
  if (e.key === "Backspace") {
    focusPrev(index);
  }
}

function handleSpaceBar(e: KeyboardEvent, index: number) {
  e.preventDefault();
  if (e.key == " ") {
    focusNext(index);
  }
}
function handlePaste ( e: ClipboardEvent, index: number){
  e.preventDefault();
  inputRefsHolderCopy.value = e.clipboardData?.getData('text').trim().split('') as any[]
  inputRefsHolderCopy.value.splice(props.inputCount)
  focusNext(index)
  emit("update:modelValue", otpValue.value);
}

watchEffect(() => {
  const updatedOtpList = props.modelValue?.slice(0, props.inputCount);
  inputRefsHolderCopy.value = [
    ...(updatedOtpList as Array<string> | Array<number>),
  ];
});

function autoFocus() {
  if (props.autoFocus) {
    inputRefsHolder.value[0].focus();
  }
}

onMounted(() => {
  autoFocus();
});
</script>

<template>
  <div class="flex items-center gap-x-[22px]">
    <input
      :type="type"
      :ref="(e) => (inputRefsHolder[index] = e)"
      :value="inputRefsHolderCopy[index]"
      @input="(e) => handleInput(e, index)"
      @keyup="(e) => handleKeyUp(e, index)"
      @keydown.space="(e) => handleSpaceBar(e, index)"
      @paste="(e) => handlePaste(e, index)"
      v-for="(input, index) in inputCount"
      :key="input"
      :placeholder="placeholder"
      :disabled="disabled"
      class="w-12 h-12 px-1 py-2 text-center disabled:cursor-not-allowed outline-none ring-[1px] ring-[#CEE3FF] focus:ring-[2px] focus:ring-Noja/Primary/600 transition-all duration-200 ease rounded text-lg text-Noja/Font/Black placeholder:text-Noja/Font/Black"
    />
  </div>
</template>
vue
<script setup lang="ts">
import { ref } from "vue";
import Otp1 from "../components/Otp/Otp1.vue";

const otp = ref([]);

function clearOtp() {
  otp.value = [];
}
</script>

<template>
  <div class="p-3 mt-4">
    <p>Your OTP is: {{ otp }}</p>
    <p>Joined Otp: {{ otp.join("") }}</p>
    <Otp1 :input-count="5" v-model="otp" />

    <button @click="clearOtp" class="rounded-md text-xs bg-gray-400 p-2 mt-3">
      Clear OTP
    </button>
  </div>
</template>

Released under the MIT License.