Skip to content

OTP Input in Vue 3

There are my requirements for the OTP input component:

  • autofocus first input on load
  • navigate with arrow keys
  • delete with backspace/delete keys
  • autofocus next input after typing
  • number input only
  • paste support
  • emit event on finish
  • reliable on mobile
  • short and simple
<template>
<div class="flex gap-2 justify-center" :class="wrapperClass">
<template v-for="(input, index) in length" :key="index">
<input
type="text"
:id="`digit-${index + 1}`"
:ref="(el) => { inputs[index] = el as HTMLInputElement }"
class="w-12 h-12 text-center text-xl"
:autofocus="index === 0"
v-model="digits[index]"
autocomplete="off"
v-bind="$attrs"
inputmode="numeric"
maxlength="1"
pattern="\d"
required
@focus="inputs[index].select()"
@mousedown.prevent="inputs[index].select()"
@input="onInput(index, $event as InputEvent)"
@paste.prevent.stop="onPaste(index, $event)"
@keydown.delete.prevent="onDelete(index)"
@keydown.left.prevent="onLeft(index)"
@keydown.right.prevent="onRight(index)"
@keydown.up.prevent
@keydown.down.prevent
/>
</template>
</div>
</template>
<script setup lang="ts">
import { nextTick, onMounted, reactive } from "vue";
const props = defineProps({
length: {
type: Number,
default: 6,
},
wrapperClass: {
type: String,
default: "",
},
});
const inputs: Array<HTMLInputElement | null> = [];
const emits = defineEmits(["onfinish", "onchange"]);
const digits = reactive(Array.from({ length: props.length }, () => ""));
function onInput(index: number, event: InputEvent) {
if (event.data) {
digits[index] = event.data;
inputs[index + 1]?.focus();
update();
}
}
function onPaste(index: number, event: ClipboardEvent) {
let paste = event.clipboardData?.getData("text") || "";
paste = paste.replace(/\D/g, "").slice(0, props.length - index);
if (paste) {
for (let i = 0; i < paste.length; i++) {
digits[index + i] = paste[i];
}
// select next closest input after paste
nextTick(() => {
const lastFilledIndex = Math.min(
index + paste.length - 1,
digits.length - 1
);
inputs[lastFilledIndex]?.focus();
});
}
update();
}
function onDelete(index: number) {
digits[index] = "";
inputs[index - 1]?.focus();
}
function onLeft(index: number) {
inputs[index - 1]?.focus();
}
function onRight(index: number) {
inputs[index + 1]?.focus();
}
function update() {
const otp = digits.join("");
emits("onchange", otp);
if (otp.length === props.length) {
emits("onfinish", otp);
}
}
onMounted(() => {
nextTick(() => {
inputs[0]?.focus();
});
});
</script>

Here is the usage of the component:

<template>
<OTPInput @onfinish="onFinish" @onchange="onChange" />
</template>
<script setup lang="ts">
import OTPInput from "@/components/OTPInput.vue";
function onFinish(otp: string) {
console.log("OTP is", otp);
}
function onChange(otp: string) {
console.log("OTP is changing to", otp);
}
</script>