Build an OTP PIN Input Component for NEXT JS App

thumbnail

Hello Guys, Today in this post we'll build a simple and interactive PIN entry component in NEXT JS 13 (TypeScript). This component is commonly used for scenarios like entering a one-time password (OTP).

Prerequisites

Before we start, make sure you have a basic understanding of NEXT JS and TypeScript. If you're new to these technologies, consider checking the official documentation and introductory tutorials.

Docs | Next.js

Setting Up the Project

Start by creating a new NEXT project using the Create Next App or your preferred setup. You can enter the app name and select the typescript

npx create-next-app@latest

Building the PIN Entry Component

Now, let's create the component that allows users to enter a PIN. We'll use state management to store each digit and refs for controlling the inputs navigation such as focus and blur.

// otp-input.tsx

import { useRef, useState } from 'react';

const maxPinLength = 4; // change as per your requirements

const OtpInput= () => {
    const [password, setPassword] = useState<number[]>(Array(maxPinLength).fill(-1));
    const inpRefs = useRef(null);
    const [activeInput, setActiveInput] = useState(-1);
    ...
};

As mentioned, we initialize the state for the PIN, use refs for managing input focus/blur, and track the active input. We also have the constant “maxPinLength” to define the pin length.

Creating the PIN Input UI

Let's continue building the UI for the PIN input. We'll create a form with input fields for each digit of the PIN.

// otp-input.tsx

...

const OtpInput = () => {
    ...

    return (
        <div className='text-center flex flex-col justify-center items-center'>
            <form onSubmit={handleSumit} ref={inpRefs} >
                <div className='flex space-x-4'>
                    {password.map((digit, i) => (
                        <div key={i} className='w-14 h-14 relative rounded-lg overflow-hidden'>
                            <label htmlFor={`pin_${i}`} className={`absolute flex justify-center items-center text-2xl top-0 left-0 w-full h-full ${activeInput == i ? "bg-gray-400" : "bg-gray-200"} opacity-100`}>{digit !== -1 ? "🤫" : "😶"}</label>
                            <input className='w-full h-full text-center border-none outline-none' id={`pin_${i}`} type='password'
                                value={digit !== -1 ? digit : ""}
                            ></input>
                        </div>
                    ))}
                </div>
                <button className='mt-10 bg-gray-900 active:border-b-2 active:border-blue-100 outline-none text-white px-4 py-2 text-lg rounded uppercase'>
                    Continue
                </button>
            </form>
        </div>
    );
};-

In this section, we create a form with input fields for each digit of the PIN by looping over the password array. We also handle the visual representation of filled and empty PIN digits.

Handling PIN Input Interactions

Let's implement the logic for handling PIN input interactions, such as focus, blur, change, and key events.

// otp-input.tsx

...

const OtpInput= () => {
    ...

    return (
        <div className='text-center flex flex-col justify-center items-center'>
            <h2 className='text-6xl font-bold mb-10'>Enter PIN</h2>
            <form onSubmit={handleSumit} ref={inpRefs} >
                <div className='flex space-x-4'>
                    {password.map((digit, i) => (
                        <div key={i} className='w-14 h-14 relative rounded-lg overflow-hidden'>
                            <label htmlFor={`pin_${i}`} className={`absolute flex justify-center items-center text-2xl top-0 left-0 w-full h-full ${activeInput == i ? "bg-gray-400" : "bg-gray-200"} opacity-100`}>{digit !== -1 ? "🤫" : "😶"}</label>
                            <input
                                onFocus={() => setActiveInput(i)}
                                onBlur={() => setActiveInput(-1)}
                                onKeyDown={(e) => handleKeyDown(e, i)}
                                onChange={(e) => handleChange(e, i)}
                                className='w-full h-full text-center border-none outline-none' id={`pin_${i}`} type='password'
                                value={digit !== -1 ? digit : ""}
                            ></input>
                        </div>
                    ))}
                </div>
                <button className='mt-10 bg-gray-900 active:border-b-2 active:border-blue-100 outline-none text-white px-4 py-2 text-lg rounded uppercase'>
                    Continue
                </button>
            </form>
        </div>
    );
};

In this section, we handle focus, blur, and key events for each PIN input. On focus we set the active input to index and onBlur we set it to -1

Implementing Event Handlers

Now, let's implement the event handlers for different interactions.

// otp-input.tsx

...

const OtpInput= () => {
    ...
    
    const handleKeyDown = (e:any, i: number)=>{
       if (e.key == "Backspace") {
         let pass = password
         pass[i] = -1
         setPassword(pass)  
         setActiveInput(i - 1)
         if (i != 0) {
           let nextInput = inpRefs?.current?.[i - 1]
           //@ts-ignore
           nextInput?.focus()
         } else {
            //@ts-ignore
            inpRefs?.current?.[i].blur()
         }
      }
    }
    const handleChange=(e:any)=>{
      // @ts-ignore
      let v = e.nativeEvent["data"]
      let pass = password
      let value = parseInt(v)
      if (!isNaN(value)) {
        pass[i] = value
        setPassword(pass)
        setActiveInput(i + 1)
        // Once the input finishes it focuses button which is the next element in the form
        let nextInput = inpRefs?.current?.[i + 1]
        //@ts-ignore
        nextInput?.focus()
      }
                                    
    }
    return (
        <div className='text-center flex flex-col justify-center items-center'>
            <h2 className='text-6xl font-bold mb-10'>Enter PIN</h2>
            <form onSubmit={handleSumit} ref={inpRefs} >
                <div className='flex space-x-4'>
                    {password.map((digit, i) => (
                        <div key={i} className='w-14 h-14 relative rounded-lg overflow-hidden'>
                            <label htmlFor={`pin_${i}`} className={`absolute flex justify-center items-center text-2xl top-0 left-0 w-full h-full ${activeInput == i ? "bg-gray-400" : "bg-gray-200"} opacity-100`}>{digit !== -1 ? "🤫" : "😶"}</label>
                            <input
                                onFocus={() => setActiveInput(i)}
                                onBlur={() => setActiveInput(-1)}
                                onKeyDown={(e) => handleKeyDown(e, i)}
                                onChange={(e) => handleChange(e, i)}
                                className='w-full h-full text-center border-none outline-none' id={`pin_${i}`} type='password'
                                value={digit !== -1 ? digit : ""}
                            ></input>
                        </div>
                    ))}
                </div>
                <button className='mt-10 bg-gray-900 active:border-b-2 active:border-blue-100 outline-none text-white px-4 py-2 text-lg rounded uppercase'>
                    Continue
                </button>
            </form>
        </div>
    );
};

In this section, we implement event handlers one is onChange where we will look for changes normally we use e.target.value to get input data for our use case we just need the current value which was typed so we are using native event data

  • let v = e.nativeEvent["data"]: Retrieves the entered digit.
  • let value = parseInt(v): Parses the digit to an integer. This ensures that only numeric values are processed.

To Update the state:

  • !isNaN(value): Checks if it is a number or not such that an update can happen only if a number is entered.
  • pass[i] = value: Updates the PIN array at the current index with the entered digit.
  • setPassword(pass): Updates the component's state with the modified PIN array.

Moving Focus to the Next Input:

  • setActiveInput(i + 1): Sets the active input index to the next one.
  • let nextInput = inpRefs?.current?.[i + 1]: Retrieves the reference to the next input element.
  • nextInput?.focus(): Sets focus on the next input element.

Note: Once it is at the last input, It will jump its focus to the button which is the next element in the form

According to my project requirements, I wanted input focus to shift to previous elements when backspace is pressed so to detect it I have used onKeyDown to capture backspace so when backspace is pressed it will shift focus to the previous element and when it is on the first digit input it will call the blur event.

otp-input-output.png

Congratulations! You've successfully built a simple PIN entry component in NEXT JS. Now for the challenge, you can add a visibility switch for the entered pin.

I hope you found this tutorial helpful. Happy coding!