When I first started with React, I didn't know much about it. I learned the basics in just one week and landed an unpaid internship during college, though I can't recall where exactly.
In that internship, my first task was to use an OTP Component. But here's the twist – I didn't create it from scratch; I had to import a library. The tricky part? I can't remember which library I downloaded back in 2017, but I do remember it was a headache. I could barely customize how it looked, or where it went on the screen.
So, I promised myself that one day, I'd create my own OTP component. Now, seven years later, I've done it. I don't want others to get as frustrated as I did.
To all my friends out there, feel free to grab this code and use it however you want. I hope it helps you in your career much sooner than it helped me. Let's get started!
Here's the final version of what I am building.
The full code is available at the end with the code sandbox link.
Part 1: Component Design
I'm using a clever method called "render prop" in React. This means you can easily fit in your design style for the OTP component. The magic happens with the renderInputProps
part. It lets you slot in your preferred look for the input fields. So, you're in control of how the OTP component looks, making it match your app's style perfectly. It's like giving you the keys to the design kingdom, making things super flexible and easy to use.
<OTP
length={4}
onCompleted={(otp) => console.log("OTP Entered is", otp)}
renderInputProps={(props) => (
<input className="input" {...props} />
)}
/>
length
: Specifies the length of the OTP (One-Time Password) to be entered.onCompleted
: A function triggered when the user successfully enters the complete OTP. In the provided example, logs the entered OTP to the console.renderInputProps
: This is where the magic happens. It's a function that takes two arguments –props
andindex
. It allows users to inject their input field design into the OTP component.
You will need to pass props in <input/> component as it gives you these things:
{
key: ..., // A unique identifier for React elements.
onClick: ..., // Function triggered when the input is clicked.
onPasteCapture: ..., // Captures and handles paste events in the input.
onChange: ..., // Handles changes in the input value.
onKeyDown: ..., // Manages keydown events, particularly handling the 'Backspace' key.
value: ..., // Represents the value of the input from the inputArr.
ref: ..., // Allows the component to keep track of input references for focus and move cursor.
}
Will explain this but first let's give you a tutorial.
Part 2: Basic version
So what do we need? we need a component that can render the amount of input boxes mentioned in the length
props. Assume we have 4 input boxes, i.e., length=4 Let's do that first.
Let's Create a state of an array for input values.
const [inputArr, setInputArr] = useState(Array(length).fill(""));
// length=4 -> inputArr=["","","",""]
As we are using render props pattern, we need to render renderInputProps
which is return an <input/>
in a function.
<div>
{Array(length)
.fill()
.map((_, index) =>
renderInputProps({
key: index,
onChange: (e) => onChange(e, index), // passing index for accesing inputArr[index]
value: inputArr[index], // will discuss this shortly
ref: null,
})
)}
</div>
The { key: index }
is props object for <input/> we will fill up later, fnName(e, index)
are the handlers I am attaching to these event handlers.
This will render input like this: (remember this as your final mental model)
<input className="input" onChange={props.onChange} value={props.value} key={props.key} ref={props.ref} />
Now let's define our onChange handler:
const onChange = (e, index) => {
// regexp for number
if (!e.target.value.match(/[0-9]/)) {
return;
}
// take last character from array
const lastChar = e.target.value.substring(e.target.value.length - 1);
setInputArr((prev) => {
const newArr = [...prev];
newArr[index] = lastChar;
return newArr;
});
};
//...
useEffect(() => {
// check if all inputs are filled
if (inputArr.join("").length === length) {
onCompleted(inputArr.join(""));
}
}, [inputArr]);
Algorithm
index
as an identifier. UseEffect
for calling our onCompleted prop method if OTP is fully enteredLet's see the Output of this Code:
Part 2: Adding automatic cursor movements
But something looks odd, it would have been better if the cursor moved to the next input box automatically after filling. This can be solved by saving each input box ref
and call ref.current.focus()
. But as input boxes are multiple let's save them in an array
const inputRefArr = useRef([]);
We also need to set individual ref to its specific input box.
<div>
{Array(length)
.fill()
.map((_, index) =>
renderInputProps({
key: index,
onChange: (e) => onChange(e, index),
value: inputArr[index],
ref: (ref) => (inputRefArr.current[index] = ref),
})
)}
</div>
The mental model of this is like this:
<input
className="input"
onChange={props.onChange}
value={props.value}
key={props.key}
ref={(ref)=>props.ref=ref}
/>
Now we can console.log
inputRefArr
and is giving us the array of input boxes.
We need to focus on the next input box if the current one is filled, that is:
const onChange = (e, index) => {
// regexp for number
if (!e.target.value.match(/[0-9]/)) {
return;
}
// take last character from array
const lastChar = e.target.value.substring(e.target.value.length - 1);
setInputArr((prev) => {
const newArr = [...prev];
newArr[index] = lastChar;
// check if all inputs are filled
if (newArr.join("").length === length) {
onCompleted(newArr.join(""));
}
return newArr;
});
// focus on next input
if (index + 1 < length && inputRefArr.current[index + 1]) {
inputRefArr.current[index + 1].focus();
}
};
Also, I want to focus on the first element when the component rendered
useEffect(() => {
if (inputRefArr.current[0]) {
inputRefArr.current[0].focus();
}
}, []);
Now Let's see our output
Now on entering backspace
key it's not doing anything, let's move the cursor to the previous input box after deleting the current input's value. this will be done using onkeyDown
handler in renderInputProps's props
const onKeyDown = (e, index) => {
if (e.key === "Backspace") {
// deleting current input's value
setInputArr((prev) => {
const newArr = [...prev];
newArr[index] = "";
return newArr;
});
// focus on previous input if present
if (index > 0 && inputRefArr.current[index - 1]) {
inputRefArr.current[index - 1].focus();
}
}
};
will have to plug this handler in the renderInputProps like this
renderInputProps({
key: index,
onChange: (e) => onChange(e, index),
onKeyDown: (e) => onKeyDown(e, index),
value: inputArr[index],
ref: (ref) => (inputRefArr.current[index] = ref),
})
Let's the output:
Part 3: Edge case
I think there is an edge case we need to consider, see this:
Edge case
|3
, and enter any number let's say 5
, the event.target.value is being set to 53
, and onChange is setting the last character, i.e. 3
, to solve this we can restrict the cursor to always be set after the last char.Let's do this in onClick
handler:
const onClick = (e, index) => {
if (inputRefArr.current[index]) {
inputRefArr.current[index].focus();
inputRefArr.current[index].setSelectionRange(1, 1);
}
};
basically setSelectionRange
is used for selecting the text from the start to end position, passing start and end both as end position, and it sets the cursor to the end of the input. (Don't forget to plug in onClick
in your renderInputProps
function).
Part 4: OTP copy-pasting feature
We want to paste OTP from the clipboard, how can we do that?
Using onPaste
handler:
const onPasteCapture = (e, index) => {
e.preventDefault();
const otp = e.clipboardData.getData("Text");
setInputArr((prev) => {
const newArr = [...prev];
for (let i = 0; i < length; i++) {
if (otp[i]) {
newArr[i] = otp[i];
}
}
return newArr;
});
if (inputRefArr.current[length - 1]) {
inputRefArr.current[length - 1].focus();
}
};
Remember to plugin it in renderInputProps
function.
Conclusion
So here is the full code for OTP.jsx
component
const OTP = ({ length, onCompleted, renderInputProps }) => {
const inputRefArr = useRef([]);
const [inputArr, setInputArr] = useState(Array(length).fill(""));
const onChange = (e, index) => {
// regexp for number
if (!e.target.value.match(/[0-9]/)) {
return;
}
// take last character from array
const lastChar = e.target.value.substring(e.target.value.length - 1);
setInputArr((prev) => {
const newArr = [...prev];
newArr[index] = lastChar;
return newArr;
});
// focus on next input
if (index + 1 < length && inputRefArr.current[index + 1]) {
inputRefArr.current[index + 1].focus();
}
};
const onKeyDown = (e, index) => {
if (e.key === "Backspace") {
setInputArr((prev) => {
const newArr = [...prev];
newArr[index] = "";
return newArr;
});
// focus on previous input
if (index > 0 && inputRefArr.current[index - 1]) {
inputRefArr.current[index - 1].focus();
}
}
};
const onPaste = (e, index) => {
e.preventDefault();
const otp = e.clipboardData.getData("Text");
setInputArr((prev) => {
const newArr = [...prev];
for (let i = 0; i < length; i++) {
if (otp[i]) {
newArr[i] = otp[i];
}
}
return newArr;
});
if (inputRefArr.current[length - 1]) {
inputRefArr.current[length - 1].focus();
}
};
const onClick = (e, index) => {
if (inputRefArr.current[index]) {
inputRefArr.current[index].focus();
inputRefArr.current[index].setSelectionRange(1, 1);
}
};
const propFn = (index) => ({
key: index,
onClick: (e) => onClick(e, index),
onPaste: (e) => onPaste(e, index),
onChange: (e) => onChange(e, index),
onKeyDown: (e) => onKeyDown(e, index),
value: inputArr[index],
ref: (ref) => (inputRefArr.current[index] = ref),
});
useEffect(() => {
if (inputRefArr.current[0]) {
inputRefArr.current[0].focus();
}
}, []);
useEffect(() => {
// check if all inputs are filled
if (inputArr.join("").length === length) {
onCompleted(inputArr.join(""));
}
}, [inputArr]);
return (
<div>
{Array(length)
.fill()
.map((_, index) => renderInputProps(propFn(index)))}
</div>
);
};
export default OTP;
Here's the code link:
Hope it will help you! Thanks :)