← Back to All Posts

webdev, javascript, react, frontend

Build OTP Input Field in React — No Package Needed

Written by: @stephengade ↔ on Jan 13

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:

OTP Demo Image credit: REDDIT

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 the handleTextChange 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 a useRef() 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.

share
thumb_up2
content_copy

Ready to work together?

I am actively seeking new opportunities and am available for remote, freelance or contract work.

If you're interested in discussing potential projects or learning more about my skills and experience, please feel free to contact me. I look forward to connecting with you and exploring how I can contribute to your organization's success remotely.