A lot of products are incorporating the idea of One-Time Password (or simply OTP) over sending users a link for them to click so they can be verified.
I'm not going to discuss the differences or advantages of using one over the other, but in this tutorial, you will learn how you can build the OTP Input component in your React application.
Previously, I have highlighted a step-by-step guide to building a tag input field without any package, you can check it out here.
If you're a front-end developer using other technologies like Vue.js, you can still learn by checking the logic of the component.
To ensure we are on the same page, this is an image showing what a typical OTP Input component looks like:
Now, let's break down our approach to solving this problem.
We will render input fried dynamically from the number of digits the OTP pin will be. For instance, if the OTP is 5 digits or characters, we will render 5 input. If it's 6 characters, we'll render 6 inputs, and so on.
When the user enters a character, the focus will be automatically set on the next and when it gets to the last one, we trigger the submit function or handle any logic we want to handle
Because we are human, we are prone to make mistakes.
When our users make mistakes entering the OTP, they can delete or clear the characters to start again.
Let's start right away.
Step 0: Bootstrap a React.js App
You can skip this step if you want to integrate into your existing project. But if you're starting afresh, you can Bootstrap a React.js application using Vite, Nextjs, or any framework of your choice.
To learn more about creating a React app, check the official documentation.
I will be using Typescript and Tailwind CSS in this tutorial, kindly take note of that during your installation. You can skip Typescript since it's a small project.
When you're done installing, cd
into your project folder, clean up the default code, and get ready for the next step.
Step 1: Create the OTP Component
In your component folder, create a new file called OTPInput.tsx
, you can use .jsx
if you don't want to use Typescript.
Inside this file, add this code:
import React, { useRef, useState, KeyboardEvent, ChangeEvent } from 'react';
type InputProps = {
length?: number;
onComplete: (pin: string) => void;
};
Note: if you're using Nextjs, don't forget to use use client
at the top of your file.
So far, all we have done is import the hooks we will be needing for our components. We then define the typing for the props we want to be lifting from this component.
length: how many digits we want to accept
onComplete is a function that will be triggered immediately when the last number is entered.
Now, to the step.
Step 2
Create a React component that lifts 2 props as defined above, and returns the input fields. Before we do that, think of a possible way we can render the input field.
Let's say you want to verify a User immediately when they sign up and you are sending 4 digits to the backend, you may think of creating 4 different input fields to handle the PIN CODE.
But what if in the same project, you need to verify the same user when he went to change his password, but now the backend is sending an 8-digit PIN CODE, are you doing to create another OTPInput
component again?
Well, it's not a smart idea.
Let's use a simple JavaScript array method called fill
... we just defined a length of props that's dynamic, right?
What we will do is to grab the length and fill it to an input field. For instance, if the length is 5, we fill it into an input and it becomes 5 input fields.
If you want to take 8 digits, it becomes 8 input fields, and so on without manually creating the 8 inputs.
So let's do just that:
const OTPInput = ({ length = 4, onComplete }: InputProps) => {
return (
<div className='grid grid-cols-4 gap-5'>
{Array.from({ length }, (_, InputIndex) => (
<input
key={InputIndex}
type='text'
maxLength={1}
className={`border border-solid border-border-slate-500 focus:border-blue-600 p-5`}
/>
))}
</div>
)
}
export default OTPInput
At this junction, we are not doing another logic, just styling our input but note how we use the Array.from(number)
.
Also, note I'm using grid-cols-4
meaning that the inputs should take 4 columns per role. You can use flex
instead of grid or alter the grid column to fit your desired style.
But now let's keep track of the input field using the hooks we imported useRef
and useState
.
Before I return the input, I will have:
//If you're not using Typescript, simply do const inputRef = useRef()
const inputRef = useRef<HTMLInputElement[]>(Array(length).fill(null));
// if you're not using Typescript, do useState()
const [OTP, setOTP] = useState<string[]>(Array(length).fill(''));
const handleTextChange = (input: string, index: number) => {
const newPin = [...OTP];
newPin[index] = input;
setOTP(newPin);
// check if the user has entered the first digit, if yes, automatically focus on the next input field and so on.
if (input.length === 1 && index < length - 1) {
inputRef.current[index + 1]?.focus();
}
if (input.length === 0 && index > 0) {
inputRef.current[index - 1]?.focus();
}
// if user has entered all the digits, grab the digits and set as an argument to the onComplete function.
if (newPin.every((digit) => digit !== '')) {
onComplete(newPin.join(''));
}
};
This is the basic logic block we need to handle the OTP Input component. But what do we have here?
We are using React's useRef
hook to store and persist each of the input values without triggering any rerender.
Then we are using the const [OTP, setOTP] = useState()
to keep track of all the values.
Again, useRef
for individual digits or input, and useState
for all the digits.
The handleTextChange
is expecting two arguments, the input and index of that input which we will pass in the returned JSX component.
In the function:
We grab the user input and set them in an array. So immediately a user types in something, it is stored in that array. And finally, it updates our OTP array using the setOTP
.
Then we access each value by its index and save each indexed value as simply input
.
The next three if-statements
help us validate the input in real time and each validation is explained with a comment in the code.
Note that the onComplete
props we passed is a function that we can trigger immediately after the input fields have been filled to submit the values to the backend.
Finally, we need to update the returned input component with our ref
and handleTextChange
.
The updated returned input is:
// ….. previous code ….
return (
<div className={`grid grid-cols-4 gap-5`}>
{Array.from({ length }, (_, index) => (
<input
key={index}
type="text"
maxLength={1}
value={OTP[index]}
onChange={(e) => handleTextChange(e.target.value, index)}
ref={(ref) => (inputRef.current[index] = ref as HTMLInputElement)}
className={`border border-solid border-border-slate-500 focus:border-blue-600 p-5 outline-none`}
style={{ marginRight: index === length - 1 ? '0' : '10px' }}
/>
))}
</div>
);
The significant change here includes:
-
Key
: Since we are mapping an array, we need to specify a unique key to each input field that will be generated. -
maxLength
: Each input field must take in a single value -
VALUE
: If a user enters 2345 for example, we are setting the value of each input field to the entered OTP concerning their index. i.e.: the first field will have 2, the second will have 3, and so on. -
onChange
: We are using thehandleTextChange
function here and we invoke the function whenever a user enters a value. -
ref
: we are keeping track of the active input field, remember we defined auseRef()
hook above.
Now we are done.
Let's stitch everything up, so here is the full OTPInput.tsx
component.
import React, { useRef, useState } from 'react';
// declare type for the props
type InputProps = {
length?: number;
onComplete: (pin: string) => void;
};
const OTPInput = ({ length = 4, onComplete }: InputProps) => {
// if you're not using Typescript, simply do const inputRef = useRef()
const inputRef = useRef<HTMLInputElement[]>(Array(length).fill(null));
// if you're not using Typescript, do useState()
const [OTP, setOTP] = useState<string[]>(Array(length).fill(''));
const handleTextChange = (input: string, index: number) => {
const newPin = [...OTP];
newPin[index] = input;
setOTP(newPin);
// check if the user has entered the first digit, if yes, automatically focus on the next input field and so on.
if (input.length === 1 && index < length - 1) {
inputRef.current[index + 1]?.focus();
}
if (input.length === 0 && index > 0) {
inputRef.current[index - 1]?.focus();
}
// if the user has entered all the digits, grab the digits and set as an argument to the onComplete function.
if (newPin.every((digit) => digit !== '')) {
onComplete(newPin.join(''));
}
};
// return the inputs component
return (
<div className={`grid grid-cols-4 gap-5`}>
{Array.from({ length }, (_, index) => (
<input
key={index}
type="text"
maxLength={1}
value={OTP[index]}
onChange={(e) => handleTextChange(e.target.value, index)}
ref={(ref) => (inputRef.current[index] = ref as HTMLInputElement)}
className={`border border-solid border-border-slate-500 focus:border-blue-600 p-5 outline-none`}
style={{ marginRight: index === length - 1 ? '0' : '10px' }}
/>
))}
</div>
);
};
export default OTPInput;
We can now use the component in our project but don't forget to pass values to the length
and handle the submit event to trigger to invoke the onComplete
props.
Here is an example to use the component.
In page
folder Create a new file called VerifyUser.tsx
and put this code:
// import the OTP Input component
import React from 'react'
import OTPInput from 'the-path'
const VerifyUser = () => {
// handle OTP Submit
const handleSubmit = (pin: string) => {
// handle api request here but I'm console logging it
console.log(pin)
}
return (
<section className='h-screen w-screen flex flex-col justify-center items-center'>
<h2>Verify OTP</h2>
<p>An OTP has been sent to your email address, kindly enter them here</p>
<OTPInput length={5} onComplete={handleSubmit} />
</section>
)
}
export default VerifyUser
What's happening:
Note that we are passing 5
as our length, meaning we will have 5 input fields for the OTP.
Then we simply pass handleSubmit
to the onComplete
props. Note that in our OTPInput.tsx
, we have passed the user-entered input values as arguments to the onComplete
function. We then invoke that function with handleSubmit
That's everything and it should be working as expected. You can of course modify the design and logic to suit your project's needs.
If you have any questions, I will be glad to answer as much as possible. Thank you for reading.
Top comments (2)
Awesome! Thank you
you didn't handle a user deleting the number with shift key backwards or delete key forward and also you did not handle clipboard paste. The clipboard paste has a difficult handling approach in firefox because it is expected that the input is type of number or tel and not text for good user experience. Maybe you can improve the code.