Today, I will show you how you can add an OTP input box to your React JS / Next JS project.
Please make sure that you have a basic knowledge of React's hooks so you can keep up with this tutorial.
I will be using the basic HTML, CSS, and Javascript logic from this snippet on Codepen, converting them to components, and then using states to modify and update the values.
PREREQUISITES
This tutorial assumes that you have a new ReactJs / NextJs installed or that you have an existing React / NextJS project.
We will aim at building something like this and of course, you are entitled to your own styling ✨
SETTING UP
Before we start off, we need to import the CSS declarations that we would use to style our input boxes.
IMPORT STYLES
Within your project, please create a CSS file otpInputs.css and import the declarations below
/** otpInputs.css **/
.digitGroup {
display: flex;
gap: 10px;
justify-content: center;
margin-bottom: 20px;
}
.digitGroup input {
outline: 0 !important;
user-select: none !important;
width: 50px;
height: 50px;
background-color: #C5DBF5;
font-weight: bold !important;
border-radius: 5px;
border: none;
line-height: 50px;
text-align: center;
font-size: 24px;
color: #001E9A;
margin: 0;
}
.digitGroup input:focus {
border: 2px solid #001E9A !important;
}
.digitGroup input:active {
border: 2px solid #001E9A !important;
}
.digitGroup .splitter {
padding: 5px 0;
color: rgb(0, 0, 0);
font-size: 25px;
margin: 0;
}
.prompt {
margin-bottom: 20px;
font-size: 20px;
color: white;
}
.formSection {
max-width: 500px;
margin: auto;
border-radius: 10px;
box-shadow: 0px 0px 8px #ddd;
padding: 20px;
}
.w-100 {
width: 100%;
}
/* Media query for mobile devices */
@media (max-width: 480px) {
.digitGroup {
gap: 5px !important;
}
.digitGroup input {
width: 40px !important;
}
.digitGroup .splitter {
font-size: 25px !important;
}
}
Next, we need to create a component that will render the input boxes and apply the styling and attributes associated with each of them.
Before we do this, I want to explain the logic / how it works.
A sample input box rendered by the otpInput
component will look like this.
<input id="input1" type="text" maxlength="1" name="input1" value="">
...
<input id="input6" type="text" maxlength="1" name="input6" value="">
The id
attribute helps us identify each input box and the maxLength
attribute ensures that the input box allows just one value.
Create The Components
Now, I will go ahead and create a file otpInputs.js and within this file, I will create a component OTPInput
that will render the input box.
I will pass in the props below;
- id - This is the unique identifier for each input box.
- previousId - This is the ID of the previous input box.
- nextId - This is the ID of the next input box. value - This is the value of a particular input.
- onValueChange - This is a callback passed as a prop that will run when the input value changes.
- handleSubmit - This is a callback that will run when the form is being submitted.
So our file will have the component below, with the properties I have explained above - what a nice wordplay if you noticed 🙂
const OTPInput = ({ id, previousId, nextId, value,
onValueChange, handleSubmit }) => {
//... Rest of the magical logic goes here
}
Now we need to add a very important logic to the component above.
We will use the keyup
event which is fired when a key is released, and within this event, we will use the code emitted by this event (keyCode) to indicate the key that was pressed. With this code, we can either move to the next input box or go back to the previous input box using the previousId
and nextId
respectively.
If there's no other input box to serve as the next one in the queue, it will check if the attribute data-autosubmit
is specified on the parent element that will group these boxes, and if the attribute is specified and it is set to true, it will submit the form.
Below I have attached the code with their respective explanations.
const OTPInput = ({ id, previousId, nextId, value, onValueChange, handleSubmit }) => {
//This callback function only runs when a key is released
const handleKeyUp = (e) => {
//check if key is backspace or arrowleft
if (e.keyCode === 8 || e.keyCode === 37) {
//find the previous element
const prev = document.getElementById(previousId);
if (prev) {
//select the previous element
prev.select();
}
} else if (
(e.keyCode >= 48 && e.keyCode <= 57) || //check if key is numeric keys 0 to 9
(e.keyCode >= 65 && e.keyCode <= 90) || //check if key is alphabetical keys A to Z
(e.keyCode >= 96 && e.keyCode <= 105) || //check if key is numeric keypad keys 0 to 9
e.keyCode === 39 //check if key is right arrow key
) {
//find the next element
const next = document.getElementById(nextId);
if (next) {
//select the next element
next.select();
} else {
//check if inputGroup has autoSubmit enabled
const inputGroup = document.getElementById('OTPInputGroup');
if (inputGroup && inputGroup.dataset['autosubmit']) {
//submit the form
handleSubmit();
}
}
}
}
}
Now it is time to return a value from our component. We will return a single input box as a JSX expression and depending on how many times this component is called, it will return the corresponding number of input boxes.
Here's the complete code for the OTPInput
component.
const OTPInput = ({ id, previousId, nextId, value, onValueChange, handleSubmit }) => {
//This callback function only runs when a key is released
const handleKeyUp = (e) => {
//check if key is backspace or arrowleft
if (e.keyCode === 8 || e.keyCode === 37) {
//find the previous element
const prev = document.getElementById(previousId);
if (prev) {
//select the previous element
prev.select();
}
} else if (
(e.keyCode >= 48 && e.keyCode <= 57) || //check if key is numeric keys 0 to 9
(e.keyCode >= 65 && e.keyCode <= 90) || //check if key is alphabetical keys A to Z
(e.keyCode >= 96 && e.keyCode <= 105) || //check if key is numeric keypad keys 0 to 9
e.keyCode === 39 //check if key is right arrow key
) {
//find the next element
const next = document.getElementById(nextId);
if (next) {
//select the next element
next.select();
} else {
//check if inputGroup has autoSubmit enabled
const inputGroup = document.getElementById('OTPInputGroup');
if (inputGroup && inputGroup.dataset['autosubmit']) {
//submit the form
handleSubmit();
}
}
}
}
return (
<input
id={id}
name={id}
type="text"
className={Styles.DigitInput}
value={value}
maxLength="1"
onChange={(e) => onValueChange(id, e.target.value)}
onKeyUp={handleKeyUp}
/>
);
};
If you noticed, we invoked a callback function on the onChange
attribute to set a new value with the corresponding input ID, if the value of the input box changed. This callback function is passed as a property in our component and with the help of state management on the parent component, we will be able to track and store all the values of the input boxes with their respective IDs.
Next, we need to create this parent component called OTPInputGroup
which will group these input boxes and render them. By rendering, I mean that it will invoke the OTPInput
6 times because we need just 6 input boxes.
This parent component will also be responsible to store the functions below;
- handleSubmit - This function handles the logic when the form is submitted
- handleInputChange - This function handles the logic when a value of an input box changes
//Our parent component
const OTPInputGroup = () => {
//state to store all input boxes
const [inputValues, setInputValues] = useState({
input1: '',
input2: '',
input3: '',
input4: '',
input5: '',
input6: '',
// Add more input values here
});
//this function updates the value of the state inputValues
const handleInputChange = (inputId, value) => {
setInputValues((prevInputValues) => ({
...prevInputValues,
[inputId]: value,
}));
};
//this function processes form submission
const handleSubmit = () => {
// ... Your submit logic here
};
}
If you look at the code above, you will notice that we have a state inputValues
that stores all the input boxes and their respective values using their IDs from input1 - input as an object.
Now, to be able to update this state, we have another function handleInputChange
which requests the input ID and the value of the input and then updates the state accordingly. This function is passed directly to the child component OTPInput
as a callback through the prop onValueChange
so it can get executed from within the child component.
Next, we have the handleSubmit
function which processes the logic when our form is submitted. You can add your API call within that function and perform other validations as well.
Now, let us render the child component 6 times and pass in the properties as well.
Here's the complete code for the parent component.
//Our parent component
const OTPInputGroup = () => {
//state to store all input boxes
const [inputValues, setInputValues] = useState({
input1: '',
input2: '',
input3: '',
input4: '',
input5: '',
input6: '',
// Add more input values here
});
//this function updates the value of the state inputValues
const handleInputChange = (inputId, value) => {
setInputValues((prevInputValues) => ({
...prevInputValues,
[inputId]: value,
}));
};
//this function processes form submission
const handleSubmit = () => {
// ... Your submit logic here
};
//return child component
return (
<>
<div id='OTPInputGroup' className={Styles.digitGroup} data-autosubmit="true">
<OTPInput
id="input1"
value={inputValues.input1}
onValueChange={handleInputChange}
previousId={null}
handleSubmit={handleSubmit}
nextId="input2"
/>
<OTPInput
id="input2"
value={inputValues.input2}
onValueChange={handleInputChange}
previousId="input1"
handleSubmit={handleSubmit}
nextId="input3"
/>
<OTPInput
id="input3"
value={inputValues.input3}
onValueChange={handleInputChange}
previousId="input2"
handleSubmit={handleSubmit}
nextId="input4"
/>
{/* Seperator */}
<span className={Styles.splitter}>–</span>
<OTPInput
id="input4"
value={inputValues.input4}
onValueChange={handleInputChange}
previousId="input3"
handleSubmit={handleSubmit}
nextId="input5"
/>
<OTPInput
id="input5"
value={inputValues.input5}
onValueChange={handleInputChange}
previousId="input4"
handleSubmit={handleSubmit}
nextId="input6"
/>
<OTPInput
id="input6"
value={inputValues.input6}
onValueChange={handleInputChange}
previousId="input5"
handleSubmit={handleSubmit}
/>
</div>
</>
);
}
Now if you noticed, before we invoked the child component, we had to group the input boxes and then there's this attribute data-autosubmit
which enables the form to submit when a user has entered the value for all input boxes.
When invoking the child component, we passed in the properties below;
- id - This is the unique Identifier for each input box
- value - This is the value of the input box. It gets its value through the
inputValues
state which is being updated using thehandleValueChange
function - onValueChange - This prop will serve as a callback function that will reference the parent function
handleInputChange
responsible for updating theinputValues
state. - previousId - This is the ID of the previous input box. This is not applicable to the first input box which is input1.
- handleSubmit - This prop will serve as a callback function that will reference the parent function
handleSubmit
responsible for submitting the form when all inputs contain values andautosubmit
is set to true - nextId - This is the ID of the next input box. This is not applicable to the last input box which is input 6.
COMPLETE CODE
Here is the complete code for the file otpInputs.js
//otpInputs.js
import React, {useState} from "react";
import Styles from './otpInput.css'; //remove this line if you are using react
//Our parent component
const OTPInputGroup = () => {
//state to store all input boxes
const [inputValues, setInputValues] = useState({
input1: '',
input2: '',
input3: '',
input4: '',
input5: '',
input6: '',
// Add more input values here
});
//this function updates the value of the state inputValues
const handleInputChange = (inputId, value) => {
setInputValues((prevInputValues) => ({
...prevInputValues,
[inputId]: value,
}));
};
//this function processes form submission
const handleSubmit = () => {
// ... Your submit logic here
};
//return child component
return (
<>
<div id='OTPInputGroup' className={Styles.digitGroup} data-autosubmit="true">
<OTPInput
id="input1"
value={inputValues.input1}
onValueChange={handleInputChange}
previousId={null}
handleSubmit={handleSubmit}
nextId="input2"
/>
<OTPInput
id="input2"
value={inputValues.input2}
onValueChange={handleInputChange}
previousId="input1"
handleSubmit={handleSubmit}
nextId="input3"
/>
<OTPInput
id="input3"
value={inputValues.input3}
onValueChange={handleInputChange}
previousId="input2"
handleSubmit={handleSubmit}
nextId="input4"
/>
{/* Seperator */}
<span className={Styles.splitter}>–</span>
<OTPInput
id="input4"
value={inputValues.input4}
onValueChange={handleInputChange}
previousId="input3"
handleSubmit={handleSubmit}
nextId="input5"
/>
<OTPInput
id="input5"
value={inputValues.input5}
onValueChange={handleInputChange}
previousId="input4"
handleSubmit={handleSubmit}
nextId="input6"
/>
<OTPInput
id="input6"
value={inputValues.input6}
onValueChange={handleInputChange}
previousId="input5"
handleSubmit={handleSubmit}
/>
</div>
</>
);
}
//Our child component
const OTPInput = ({ id, previousId, nextId, value, onValueChange, handleSubmit }) => {
//This callback function only runs when a key is released
const handleKeyUp = (e) => {
//check if key is backspace or arrowleft
if (e.keyCode === 8 || e.keyCode === 37) {
//find the previous element
const prev = document.getElementById(previousId);
if (prev) {
//select the previous element
prev.select();
}
} else if (
(e.keyCode >= 48 && e.keyCode <= 57) || //check if key is numeric keys 0 to 9
(e.keyCode >= 65 && e.keyCode <= 90) || //check if key is alphabetical keys A to Z
(e.keyCode >= 96 && e.keyCode <= 105) || //check if key is numeric keypad keys 0 to 9
e.keyCode === 39 //check if key is right arrow key
) {
//find the next element
const next = document.getElementById(nextId);
if (next) {
//select the next element
next.select();
} else {
//check if inputGroup has autoSubmit enabled
const inputGroup = document.getElementById('OTPInputGroup');
if (inputGroup && inputGroup.dataset['autosubmit']) {
//submit the form
handleSubmit();
}
}
}
}
return (
<input
id={id}
name={id}
type="text"
className={Styles.DigitInput}
value={value}
maxLength="1"
onChange={(e) => onValueChange(id, e.target.value)}
onKeyUp={handleKeyUp}
/>
);
};
export default OTPInputGroup;
In our project, we can now import this component and use it.
import React from 'react';
import OTPInputGroup from './path/to/otpInput.js';
export default function ResetPage(){
return (
<>
<OTPInputGroup />
</>
)
}
That's all 🙂
If you followed the tutorial correctly, you should arrive at something like this. Go ahead and insert a value, you will notice that it moves you over to the next input box, and when you delete a value or use backspace, it moves you back to the previous input box, this is where the keyUp
event comes to play.
BEFORE YOU GO…
If you are using NextJS, you can use the code as it is including the Styles already imported. But if you are using ReactJs, please modify the code by importing the styles directly and then modifying the CSS declarations.
It's been a while since I wrote my last article. This is because I have some other projects currently that I am working on and it doesn't stop me from sharing the best practices used in each of them.
If you loved this article, please don't hesitate to follow me on my social handles. Let's build GREAT things together!
Happy coding!
…Off you go now, do well to stop by some other time 🥲
Photo credit: Lautaro Andreani On Unsplash
Top comments (1)
This code skips values, if you're typing fast