Vue 2 and Vue 3 OTP/PIN Form Tutorial: Implementing a Headless Technique for Seamless Results

Usman Alabura
Stackademic
Published in
12 min readSep 25, 2023

--

Creating a Headless UI OTP/PIN Input Field
Creating a Headless UI OTP/PIN Input Field

In the ever-expanding world of fintech and banking solutions, the demand for seamless authentication and verification processes has never been higher. One common challenge faced by Vue developers is crafting user-friendly one-time password (OTP) and PIN input forms.

Traditional input fields are straightforward — they take an input and display the value. However, we aspire to offer our users an exceptional experience. We envision input fields that gracefully split and progress as users interact with the form. While various packages exist for this purpose, many lack the flexibility and customization we seek.

In this tutorial, we embark on a journey to create a distinctive OTP form field using a Headless approach, all without relying on external packages.

Prerequisite

This guide is designed for developers who already have experience with Vue.js. Whether you’re working on a project that requires a flexible OTP form or you’re creating a component library for your team, this guide is tailored to meet your needs. Prior knowledge of Vue.js is essential to make the most of this resource.

Tutorial Overview

In this tutorial, we will focus on implementing the OTP feature using Vue 3 with the Options API. For those interested in the Composition API, a separate resource will be provided in a future course or article. This tutorial aims to provide a comprehensive understanding of OTP implementation in Vue.js.

Get a cup of coffee, and let’s dive right into it!

Tutorial sections

  • Environmental Setup: We’ll start by setting up the necessary environment and tools to begin working on our OTP/PIN input field project. This step will ensure you have everything in place to proceed smoothly.
  • Creating the Required Files: Next, we’ll create the essential files and project structure needed for our OTP/PIN input field. Organizing your project effectively is a crucial step in building a maintainable application.
  • Writing the Logic: Here, we’ll dive deep into the code and logic required to build our Headless UI OTP/PIN input field. We’ll explore different aspects of Vue 2 and Vue 3, leveraging the Options API and Composition API to create a robust and user-friendly component.
  • Usage: Finally, we’ll demonstrate how to utilize the OTP/PIN input field component within your Vue applications. You’ll learn how to integrate this feature seamlessly into your projects, enhancing user authentication and verification processes.

By the end of this tutorial, you’ll have a solid grasp of creating a versatile OTP/PIN input field in Vue 3, allowing you to take your Vue development skills to the next level. Let’s get started!

Environmental Setup

To set up the OTP component, follow these steps in your terminal:

  1. Navigate to the desired directory using the terminal:
cd desired-directory

2. Run the following command to create a Vue project named “otp-component”:

vue create otp-component

Note: If `Vue` is not installed on your system, execute the following command:

# Install Vue globally (if not already installed)
npm install -g @vue/cli

Then proceed with the ‘vue create otp-component’ command.

You will encounter a menu resembling the image below. From there, simply choose ‘Vue 3’.

We opt for ‘Vue 3’ due to its flexibility, allowing us to harness both the ‘Options API’ and the ‘Composition API’ for our development needs.

Creating the Required Files

After you’ve finished creating the Vue 3 app, proceed to create the following project files as follows:

otp-component/
├── src/
│ ├── components/
│ │ ├── otp/
│ │ │ ├── Otp.Component.vue
│ │ │ ├── OtpGroup.Component.vue
│ │ │ ├── OtpGroupInput.Component.vue
│ │ │ ├── OtpErrorMessage.Component.vue
│ │ │ ├── index.js
│ ├── views/
│ │ ├── App.vue

Writing the Logic

Alright, let’s dive into the exciting world of component logic! Grab your virtual coding gear as we open up the Otp.Component.vue file and embark on this Vue.js adventure! 🚀💻

Otp.Component.vue


<template>
<div name="otp">
<!-- Here, we define a slot to pass data to our component -->
<slot
:digits="digits"
:is-valid="isValid"
:on-blur="onBlur"
:on-input="onInput"
:on-paste="onPaste"
/>

<!-- Another slot for handling errors -->
<slot name="error" :message="errorMessage" :hasError="error" />
</div>
</template>

In the template section:
We set up the basic structure of our component. It’s like building a house with rooms that can be customized later. We create slots where we can insert data from the outside.

Now, get ready for the exciting part — the script section where all the magic happens!

<script>
import { defineComponent } from "vue";

export default defineComponent({
name: "otp-component", // Giving our component a name

props: {
modelValue: String, // This is where we receive input from the user
count: { // This is the place where users will specify the number of input fields.
type: Number,
required: true,
},
},

data() {
return {
error: false, // We start without errors
errorMessage: "", // No error message initially
digits: Array(this.count).fill(""), // Create an array to store our OTP digits
};
},

computed: {
isValid() {
return this.digits.length === this.count; // We check if all OTP digits are filled
},
},

mounted() {
if (this.modelValue) this.digits = this.modelValue?.split("");
// When our component is ready, we check if there's already an OTP provided and fill it in
},

methods: {
onInput(index, digit) {
// This function handles user input for each OTP digit
switch (typeof digit) {
case "string":
this.digits[index] = digit;
break;
case "number":
this.digits[index] = digit?.toString();
break;
default:
this.digits[index] = digit?.data ? digit?.data : "";
break;
}

this.$emit("update:modelValue", this.digits?.join(""));
// We update the OTP digits and notify the parent component of changes
},

onPaste(index, event) {
event.preventDefault();
const clipboardData = event?.clipboardData || window?.clipboardData;

if (clipboardData) {
const pastedText = clipboardData.getData("text").trim();

// Remove non-digit characters from the pasted text
const parsedPastedText = pastedText.replace(/\D/g, "");

// Initialize an array for the new digits
let newDigits = Array(this.count).fill("");

// Check if the pasted text length exceeds the available input fields
if (this.digits.length <= parsedPastedText?.length) {
this.errro = true;
this.errorMessage = "Invalid characters in the pasted OTP.";
}

// Copy parsed digits into the newDigits array, starting from the current index
for (let i = 0; i < parsedPastedText.length; i++) {
if (index + i < newDigits.length) {
newDigits[index + i] = parsedPastedText[i];
}
}

// Update the digits with the newly pasted values
this.digits = newDigits;
} else {
// Handle cases where clipboard data is unavailable
this.error = true;
this.errorMessage = "Invalid characters in the pasted OTP.";
}
},

onBlur() {
// This function is called when the input field loses focus
this.checkDigits();
},

clearDigits() {
// This function clears all OTP digits and errors
this.error = false;
this.digits.fill("");
this.errorMessage = "";
},

checkDigits() {
// You can add custom validation logic here
if (!this.isValid) {
this.errorMessage = "Please enter a valid OTP";
this.error = true;
} else {
this.errorMessage = "";
this.error = false;
}
}
}
});
</script>

In the script section:

  • It receives user input via the modelValue prop and allows users to specify the number of input fields with the count prop.
  • The component maintains a state with error, errorMessage, and an array of digits.
  • It computes whether the OTP is valid by checking if all digits are filled.
  • When mounted, it initializes the digits array with values from modelValue.
  • The onInput method handles user input for each OTP digit and emits changes to the parent component.
  • onPaste handles pasted content, sanitizes it, and updates the OTP digits.
  • onBlur is triggered when the input field loses focus and can perform custom validation.
  • clearDigits clears all OTP digits and errors.
  • The component emits updates to the parent component using the update:modelValue event.
  • It provides flexibility for OTP input and error handling.

Next, let’s work on the OtpGroup.Component

OtpGroup.Component.vue

<template>
<div name="otp-group" ref="otpGroup">
<slot :focus-next="focusNext" :focus-prev="focusPrev" />
</div>
</template>

In the template section:

  • We use the ref attribute to assign a reference to this <div> element, which we name "otpGroup."
  • We bind the focus-next and focus-prev slot props to the focusNext and focusPrev methods defined in the component's script section.
<script>
import { defineComponent } from "vue";

export default defineComponent({
name: "otp-group",

methods: {
focusNext(index) {
if (this.$refs.otpGroup?.children[index + 1]) {
this.$refs.otpGroup.children[index + 1]?.focus();
}
},

focusPrev(index) {
if (index > 0) {
this.$refs.otpGroup.children[index - 1]?.focus();
}
}
}
});
</script>

In the script section:

  • Within this component, we define two methods: focusNext and focusPrev.
  • focusNext(index) is a method that receives an index parameter. It is used to focus on the next child element within the <div> with the reference "otpGroup" when called. It checks if there is a next child element, and if so, it sets the focus on it.
  • focusPrev(index) is a method that receives an index parameter. It is used to focus on the previous child element within the <div> with the reference "otpGroup" when called. It checks if there is a previous child element (by ensuring the index is greater than 0), and if so, it sets the focus on it.

Overall, this component acts as a container for a slot, allowing the parent component to pass in content with focus-next and focus-prev slot props. The focusNext and focusPrev methods enable navigation between child elements within the container.

Let’s implement the OtpGroupInput.Component

`OtpGroupInput.Component.vue

<template>
<input
v-model="digit"
type="text"
:maxlength="1"
/>
</template>

In the template section:

  • We define an <input> element, which is a text input field.
  • We use v-model to bind the digit data property to the input's value. This means that the input's value will be controlled by the digit property in the component's data.
  • The input has type="text", indicating that it's a text input.
  • We use :maxlength="1" to set the maximum length of the input to 1 character.
<script>
import { defineComponent } from "vue";

export default defineComponent({
name: "otp-group-input",

props: {
value: String,
},

data() {
return {
digit: "",
};
},

watch: {
value() {
this.setIntialValue();
},

digit(newValue, oldValue) {
if (newValue !== oldValue) {
if (newValue === "") {
this.emitPrev();
} else {
this.emitNext();
}
}
},
},

methods: {
emitPrev() {
this.$emit("prev");
},
emitNext() {
this.$emit("next");
},

setIntialValue() {
switch (typeof this.value) {
case "string":
this.digit = this.value;
break;
case "number":
this.digit = this.value?.toString();
break;

default:
this.digit = this.value?.data ? this.value?.data : "";
break;
}
}
}
});
</script>

In the script section:

  • The component accepts a value prop of type String, which will be used to set the initial value of the input.
  • In the data section, we define a digit property, initially set to an empty string. This property represents the value of the input field.
  • We use the watch section to watch for changes in the value and digit properties:
  • When the value prop changes, the value watcher calls the setInitialValue method to set the digit property based on the value prop's type.
  • When the digit property changes, the digit watcher checks if it's empty and emits either a "prev" or "next" event using the $emit method, depending on whether the input is cleared or a new digit is entered.
  • The emitPrev and emitNext methods emit "prev" and "next" events, respectively, which can be used by the parent component to navigate between input fields.
  • The setInitialValue method sets the digit property based on the type of the value prop, handling cases where value is a string, number, or an object with a data property.

This component represents an individual input field for an OTP (One-Time Password) and provides functionality for handling input, emitting events, and setting initial values based on the provided value.

Finally, let’s implement the OtpErrorMessage.Component

OtpErrorMessage.Component.vue

<template>
<div v-show="hasError" name="otp-error-message">
{{ displayMessage }}
<slot />
</div>
</template>

In the template section:

  • We have a <div> element that is conditionally displayed using v-show="hasError". This means the <div> will only be visible when the hasError prop is true.
  • Inside the <div>, we display the displayMessage computed property using {{ displayMessage }}. This displays an error message if displayMessage is not empty.
  • Additionally, there is a <slot /> element. This slot allows the parent component to insert content within this <div>. Any content provided by the parent will be rendered in place of the <slot />.
<script>
import { defineComponent } from "vue";

export default defineComponent({
name: "otp-error-message",

props: {
message: String,
hasError: Boolean,
},

computed: {
displayMessage() {
return this.message ? this.message : "";
},
},
});
</script>

In the script section:

  • The component accepts two props:
  • message: A String prop that represents an error message.
  • hasError: A Boolean prop that indicates whether an error is present or not.
  • Inside the computed section, we define a computed property named displayMessage. This computed property checks if the message prop is provided and returns it. If message is empty or not provided, it returns an empty string.

This component is designed to display an error message conditionally based on the hasError prop and allows the parent component to insert custom content within the error message <div> using slots. The displayMessage computed property ensures that the error message is only displayed when the message prop has content.

Now, let’s export all the component files.

index.js

import Otp from './Otp.Component.vue';
import OtpGroup from './OtpGroup.Component.vue';
import OtpGroupInput from './OtpGroupInput.Component.vue';
import OtpErrorMessage from './OtpErrorMessage.Component.vue';

export {
Otp,
OtpGroup,
OtpGroupInput,
OtpErrorMessage
};

The index.js file in this context serves as an entry point for exporting multiple Vue components from the "otp" folder. It acts as a module, making all the components easily accessible and importable for use in different parts of the application.

Usage

You can seamlessly integrate the Headless OTP Component into your App.vue by following these steps:

App.vue

<template>
<otp v-model="pin" :count="5" ref="otpComponent">
<template v-slot="{ digits, onInput, onPaste, onBlur }">
<div class="flex items-center space-x-4">
<otp-group class="flex space-x-2 p-2">
<template v-slot="{ focusNext, focusPrev }">
<otp-group-input
v-for="(digit, index) in digits"
:key="index"
:value="digit"
autofocus
placeholder="*"
@blur="onBlur"
@next="focusNext(index)"
@prev="focusPrev(index)"
@paste="onPaste(index, $event)"
@input="onInput(index, $event)"
class="h-12 w-12 rounded-xl text-3xl text-center text-slate-700 caret-teal-400 border-2 border-slate-300 placeholder:text-slate-400 focus:outline-none focus:border-teal-400"
/>
</template>
</otp-group>

<button
class="w-8 h-8 rounded-full transition-color text-slate-700 hover:text-teal-500"
@click="clearPin"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-8 h-8"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
</div>
</template>

<template v-slot:error="{ message, hasError }">
<otp-error-message :message="message" :hasError="hasError" />
</template>
</otp>
</template>

<script>
import { defineComponent } from "vue";

// COMPONENTS
import { Otp, OtpGroup, OtpGroupInput, OtpErrorMessage } from "@/components/otp";

export default defineComponent({
name: "App",

components: {
Otp,
OtpGroup,
OtpGroupInput,
OtpErrorMessage,
},

data() {
return {
pin: "",
};
},

watch: {
pin(pin) {
console.log({ pin });
},
},

methods: {
clearPin() {
this.pin = "";
this.$refs.otpComponent.clearDigits();
},
},
});
</script>

Summary:

The OTP component provides a powerful and flexible solution for implementing One-Time Password (OTP) input fields in your Vue.js applications. With its user-friendly features and easy integration, you can enhance your app’s authentication and verification processes. Here’s a summary of its usage:

  1. Component Structure:
  • The otp component is at the core of OTP input handling.
  • It allows you to specify the OTP length with the :count prop.
  • You can easily access its methods and properties using a ref (in this example, ref="otpComponent").

2. Customization and Styling:

  • You have complete control over the styling and structure of the OTP input fields.
  • The otp component utilizes slots to give you full control over how individual digits are rendered.
  • The otp-group component is used to group the OTP input fields together, and you can apply custom classes and styles.
  • otp-group-input represents each individual digit input field, and you can customize its appearance and behavior.

3. User Interaction:

  • The component handles various user interactions seamlessly.
  • It supports keyboard navigation for moving between OTP input fields using the @next and @prev events.
  • Users can paste OTP codes from their clipboard, and the component validates and populates the fields accordingly.

4. Validation and Error Handling:

  • The component provides built-in validation to ensure that users enter a valid OTP.
  • It can display error messages for invalid inputs, enhancing user feedback.

5. Clearing OTP Input:

  • You can easily clear the OTP input by calling the clearDigits method, making it user-friendly.

Advantages and Flexibility:

  • The OTP component leverages Vue.js, allowing for seamless integration into your Vue applications.
  • It offers a structured and modular approach to OTP input handling, making it easy to understand and maintain.
  • Full customization and styling control enable you to match the component with your application’s design.
  • Keyboard navigation and paste functionality enhance user experience and convenience.
  • Built-in validation and error handling ensure data accuracy and user-friendly error messages.
  • The component’s clear method simplifies input clearing, reducing user frustration.

With the OTP component, you can effortlessly enhance your Vue.js application’s authentication and verification processes while maintaining full control over its appearance and behavior.

Below is an illustrative example showcasing the component’s usage, coupled with styling crafted using Tailwind CSS for a visually appealing and responsive design.

Usage Illustration.

Feel free to utilize the enclosed CodeSandbox to conveniently explore the codebase for your reference.

Note: Stay tuned for my upcoming tutorial series, where we’ll delve into the development of the Headless OTP component. I’ve got you covered whether you’re a Vue 3 enthusiast looking to harness the power of the Composition API or a React aficionado eager to explore OTP component integration with React and Next.js.

If you found this tutorial helpful, please consider giving it a clap. Your support means the world to me. Follow for more insightful content!

Cheers to your coding success! 🥂

Stackademic

Thank you for reading until the end. Before you go:

  • Please consider clapping and following the writer! 👏
  • Follow us on Twitter(X), LinkedIn, and YouTube.
  • Visit Stackademic.com to find out more about how we are democratizing free programming education around the world.

--

--

5+ years crafting solutions with Next/React/Vue/Angular/React Native/Node. Solving biz puzzles, and staying ahead to help you write better code.