Introduction
Have you ever wondered how the one-time password screens on an app or webpage work? These are the screens that have multiple input elements that accept only one character. All these elements behave as if they are one element and have a behavior similar to that of the normal input element. We can call this a passcode component because a one-time password is nothing but a passcode.
In this blog post, we are going to talk about how to create this passcode component. We are going to understand how this component works and the various use cases involved with it. Later in the post, we start with the implementation of the component.
So without further ado, let us get started.
What is a Passcode component?
A Passcode component is a group of input boxes. Each character in this component has its own input element and collectively we call this the passcode component. A passcode component looks like the below:
It is used by most of the mobile and web applications these days. It is a common UI practice that is being followed during any validation flow. But it is trickier to implement since it's not a simple input element but is a group of input elements. There are multiple use case scenarios that need to be managed like handling key events, pasting experience, making sure the focus is changing as expected, etc.
Now let us start with understanding the use cases involved in the passcode component. So without further ado let us get started.
Cases involved in a Passcode component
Following are the basic scenarios that we are going to cover while building the Passcode component:
- All the input boxes should accept only one character
- Once a character is pressed the focus should shift to the next input element
- While backspacing the focus should shift from right to left input element
We are also going to cover one advanced scenario: Pasting experience. It involves the following sub-cases:
- Check for user permissions
- Focus should go to the last input element when the pasted value length is equal to the number of input elements.
- The focus should shift to the input element that contains the last character of the pasted value when pasted value's length is less than the total number of elements.
- The focus should still be on the last input element when the pasted value length is greater than the total number of input elements
- If a user tries to paste the value from an input element that is in between the start and end then, the pasted value is partially filled till the end of the input elements.
Setting up the project
-
We will be using the create-react-app's typescript template to initialize our project. Run the below command to create a project. Make sure to specify the name of the project in the placeholder:
npx create-react-app <name-of-the-project> --template typescript
-
Once the project is initialized, we add a couple of folders and files in it.
├── src │ ├── App.css │ ├── App.test.tsx │ ├── App.tsx │ ├── components │ │ └── Passcode.tsx │ ├── index.css │ ├── index.tsx │ ├── logo.svg │ ├── react-app-env.d.ts │ ├── reportWebVitals.ts │ ├── setupTests.ts │ └── utils │ └── index.ts
We create 2 new folders in it namely:
components
utils
. We also create the files mentioned in all these folders. We will later take a look at what each of these files means.-
Once these folders are created make sure to start your project by running the following command:
yarn start
Building the Passcode component
To build a passcode component first we need to understand how it works. Refer to the below diagram to understand it's working.
Consider the above diagram. Each box shown above references each input element in the passcode component. If you enter a value inside the first input then two operations happen:
- The value state variable is updated and,
- The focused state gets updated.
We make use of various event handlers such as onChange
, onKeyDown
, onKeyUp
, etc. to manage the focus between multiple input elements and the change in the value state variable. We will take a closer look at how these event handlers are orchestrated in the next section.
At this point, both the focused index and the value props are updated. This triggers a re-render and thus renders the passcode component with the next input element to be focused.
So this is how our component is going to work in laymen's terms.
Now that we have covered the basics, we can move on to addressing each use case mentioned above
Use case implementation
We will start by creating the scaffoldings for our project. Follow along for the same:
-
First, we will create a passcode component. This component acts as a container component. It will hold the data and it will render each input component.
const Passcode = () => { const [arrayValue, setArrayValue] = useState<(string | number)[]>(['', '', '','']); const [currentFocusedIndex, setCurrentFocusedIndex] = useState(0); return ( <> <div> currentFocusedIndex: {currentFocusedIndex}</div> {arrayValue.map((value: string | number, index: number)=> ( <input key={`index-${index}`} type="text" value={String(value)} /> ))} </> ); }
The passcode component created has two state variables:
arrayValue
andcurrentFocusedIndex
.arrayValue
contains the actual value of the passcode component. If any of the underlying input elements change their value then this state variable is also updated. We also havecurrentFocusedIndex
that contains the current index of the input element that needs to be focused. This gets updated whenever the user clicks on the input element or types in the input element.
Case 1: All the input boxes should accept only one character
Now let us start with our first use case. To implement this case we just need to add maxLength
its value being set to 1
. This allows us to accept only one character.
const Passcode = () => {
const [arrayValue, setArrayValue] = useState<(string | number)[]>(['', '', '','']);
const [currentFocusedIndex, setCurrentFocusedIndex] = useState(0);
return (
<>
<div> currentFocusedIndex: {currentFocusedIndex}</div>
{arrayValue.map((value: string | number, index: number)=> (
<input
key={`index-${index}`}
maxLength={1}
type="text"
value={String(value)}
/>
))}
</>
);
}
This is an optional step, but if you want to control what type of keyboard to be displayed when the users fill this component and only want to allow numerals to be entered then simply add the inputMode
attribute and set it to numeric. This will make sure that a numeric virtual keyboard is displayed when the user types via mobile screen:
<input
key={`index-${index}`}
inputMode="numeric"
pattern="\d{1}"
maxLength={1}
type="text"
value={String(value)}
/>
Case 2: Once a character is typed the focus should shift to the next input box
By far this is going to be the most crucial use case to solve because this use case defines the basic interaction of this component. To solve it we need to consider a couple of things:
- To control the focus of each input element we need to have an actual control of the input element. To achieve this, we need to pass the ref attribute to each input element.
- We are going to segregate the task of focus and updating arrayValue the attribute in different event handlers:
-
onChange
- will handle the update part of arrayValue state variable -
onKeyup
- will be responsible to update the focused index state variable. -
onKeyDown
- will be responsible to prevent typing any other keys except for the numerics. -
onFocus
- will make sure that we update the current element's focus when clicked.
-
Let us start by adding ref
attribute to each input element. To do this, we should create an array as a reference. This array will store the reference of each input element.
-
To do this we declare an array as a reference value as follows:
const inputRefs = useRef<Array<HTMLInputElement> | []>([]);
-
Next, we make sure that we add a reference of each element into the array by doing the following:
<input key={`index-${index}`} ref={(el) => el && (inputRefs.current[index] = el)} // here inputMode="numeric" maxLength={1} type="text" value={String(value)} />
We use the ref
callback mechanism to get the current element. You can read more about ref
callback function here. Next, we assign this current element at the ith index of inputRefs
array.
In this way, we store the reference of each input element in an array of ref
. Now let us take a closer look at how we are going to use these references.
A passcode component only accepts a single numeric character in each of its input elements. We just need to restrict our passcode component so as to not accept any non-numeric values. In some cases, it can also accept alphanumeric code value but that's currently out of the scope of this blogpost. On the overall component, we are trying to restrict users by only typing numeric values in each input element. What we can do is simply prevent the default behavior of the key event on specific keystrokes. Here is what we will do:
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
const keyCode = e.key;
if (!(keyCode >= 0 && keyCode <= 9)) {
e.preventDefault();
}
};
The above code ensures that the default behavior, which is preventing keystrokes for all non-numeric values, is implemented. We determine whether the key pressed is numeric or not using e.key
. The Keyboard events key
attribute returns the value of the key pressed by the user. Since we want to allow this behavior, we do not execute e.preventDefault()
for non-numeric values. Therefore, this method helps us restrict the typing to only numeric values.
Next, we understand how to update the state variable arrayValue
. We update it when onChange
the event occurs. Here is how you can update this state:
const onChange = (e: BaseSyntheticEvent, index: number) => {
setArrayValue((preValue: (string | number)[]) => {
const newArray = [...preValue];
if (parseInt(e.target.value)) {
newArray[index] = parseInt(e.target.value);
} else {
newArray[index] = e.target.value;
}
return newArray;
});
};
Here we make sure that the value provided by the onChange
event is numeric or not. If yes, we set it in the clone variable newArray
.
Next, we have a look at the onKeyUp
event handler. In this event handler, we update the state variable currentFocusedIndex
. Below is the code that helps you update this state variable:
const onKeyUp = (e: KeyboardEvent<HTMLInputElement>, index: number) => {
if (parseInt(e.key) && index < arrayValue.length - 1) {
setCurrentFocusedIndex(index + 1);
if (inputRefs && inputRefs.current && index === currentFocusedIndex) {
inputRefs.current[index + 1].focus();
}
}
};
Here the first update that we do is to set the currentFocusedIndex
value to index+1
. Next, we set the next input element into the focused state with the help of the focus
function. In this way, the next element is focused as well as the code is now ready to handle the same set of events again for the next input element.
Finally, we have the onFocus
event handler. The purpose of this handler is to update the currentFocusedIndex
index of the input element that is being manually focused by a click event.
const onFocus = (e: BaseSyntheticEvent, index: number) => {
setCurrentFocusedIndex(index);
};
We stitch all these handlers together and this is how our passcode component will look like:
const Passcode = () => {
const [arrayValue, setArrayValue] = useState<(string | number)[]>([
"",
"",
"",
""
]);
const [currentFocusedIndex, setCurrentFocusedIndex] = useState(0);
const inputRefs = useRef<Array<HTMLInputElement> | []>([]);
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
const keyCode = e.key;
if (!(keyCode >= 0 && keyCode <= 9)) {
e.preventDefault();
}
};
const onChange = (e: BaseSyntheticEvent, index: number) => {
setArrayValue((preValue: (string | number)[]) => {
const newArray = [...preValue];
if (parseInt(e.target.value)) {
newArray[index] = parseInt(e.target.value);
} else {
newArray[index] = e.target.value;
}
return newArray;
});
};
const onKeyUp = (e: KeyboardEvent<HTMLInputElement>, index: number) => {
if (parseInt(e.key) && index <= arrayValue.length - 2) {
setCurrentFocusedIndex(index + 1);
if (inputRefs && inputRefs.current && index === currentFocusedIndex) {
inputRefs.current[index + 1].focus();
}
}
};
const onFocus = (e: BaseSyntheticEvent, index: number) => {
setCurrentFocusedIndex(index);
e.target.focus();
};
return (
<>
<div> currentFocusedIndex: {currentFocusedIndex}</div>
<div>{arrayValue}</div>
{arrayValue.map((value: string | number, index: number) => (
<input
key={`index-${index}`}
ref={(el) => el && (inputRefs.current[index] = el)}
inputMode="numeric"
pattern="\d{1}"
maxLength={1}
type="text"
value={String(value)}
onChange={(e) => onChange(e, index)}
onKeyUp={(e) => onKeyUp(e, index)}
onKeyDown={(e) => onKeyDown(e)}
onFocus={(e) => onFocus(e, index)}
/>
))}
</>
);
};
Case 3: While backspacing the focus should shift from right to left
This is a pretty common use case where the user wants to clear each value that is entered with the press of the backspace
key. To achieve this we need to make sure that we update the currentFocusedIndex
in onKeyUp
event. We also need to allow the pressing of the Backspace
key, to do that we add a condition inside the onKeyDown
event.
Update the onKeyUp
and onKeyDown
event handler as follows:
const onKeyUp = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Backspace") {
if (index === 0) {
setCurrentFocusedIndex(0);
} else {
setCurrentFocusedIndex(index - 1);
if (
inputRefs &&
inputRefs.current &&
index === currentForcusedIndex
) {
inputRefs.current[index - 1].focus();
}
}
} else {
if (
(parseInt(e.key)) &&
index <= array.length - 2
) {
setCurrentFocusedIndex(index + 1);
if (
inputRefs &&
inputRefs.current &&
index === currentForcusedIndex
) {
inputRefs.current[index + 1].focus();
}
}
}
};
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
const keyCode = e.key;
if (
!(keyCode >= 0 && keyCode <= 9) &&
keyCode !== "Backspace"
) {
e.preventDefault();
}
};
Here we added a block that handles the currentFocusedIndex
and the focus of the input element when the Backspace key is pressed. First, if the current index is zero we also update the currentFocusedIndex
to it else we set it to index-1
i.e. the focus is set to the previous element. We also make sure that we focus on the previous element by calling the focus function of the previous element. Inside the onKeyDown
event handler, we added a condition to not execute preventDefault
the function when keyCode
is Backspace
. This makes sure that the backspace key can be pressed and performs its default behavior.
Case 4: Pasting experience
This use case has multiple sub-use cases.
a. Check for user permissions
b. Focus should go to the last input box when the pasted value length is equal to the number of input boxes
c. The focus should shift to the input box that contains the last character of the pasted value when pasted value's length is less than the total number of input elements
d. The focus should be on the last input box when the pasted value length is greater than the total number of boxes
e. If a user tries to paste the value from an input box that is in between the start and end then, the pasted value is partially filled till the end of the input boxes
Now let us get started with the implementation of the above sub-use cases. The Pasting Experiences
scenario is carried out entirely inside the useEffect hook. We add an event listener to the paste
event.
Here is what our code will look like:
useEffect(() => {
document.addEventListener("paste", async () => {
// Handle all sub-usecases here
});
}, [])
To allow the pasting of values we need to allow the pressing of CRTL + V
key combinations so that users can paste the value via the keyboard. We need to add this condition inside onKeyDown
event handler to allow this key combination:
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
const keyCode = parseInt(e.key);
if (
!(keyCode >= 0 && keyCode <= 9) &&
e.key !== "Backspace" &&
!(e.metaKey && e.key === "v")
) {
e.preventDefault();
}
};
Case a: Check user permission
To check for permission of pasting we make use of the Navigator interface's permission read-only property. You can read more about this property here. We make use of the query function to get the current permission. In our scenario, we are required to get the clipboard-read
permission. We use the below code to do the same:
useEffect(() => {
document.addEventListener("paste", async () => {
// Handle all sub-usecases here
const pastePermission = await navigator.permissions.query({
name: "clipboard-read" as PermissionName
});
if (pastePermission.state === "denied") {
throw new Error("Not allowed to read clipboard");
}
});
}, []);
With this code, we make sure that we ask for the user's permission before pasting the data. The permission dialog box will look like the below:
Next, we manage the focus of the passcode component while pasting the numeric value. For cases: b, c, d, and e we are going to manage them in one go since all of them are related to focus management. Before we start with pasting experience and focus management we should make sure that all the values from the clipboard are numbers. But before that, we should read the content from the clipboard:
const clipboardContent = await navigator.clipboard.readText();
Next, we convert all the content from the clipboard to a number:
try {
let newArray: Array<number | string> = clipboardContent.split("");
newArray = newArray.map((num) => Number(num));
} catch (err) {
console.error(err);
}
To create a pasting experience, we update arrayValue
the contents of the clipboard. We update the arrayValue
but based on the currentFocusedIndex
:
-
If
currentFocusedIndex > 0
i.e. if pasting gets started from any input element except the first and the last input element, then we need to fill the arrayValue from the focused input element to the last input element.- We first calculate the number of places/input elements that are available from the currently focused index to the last input element. We call this variable as remainingPlaces.
- Now we know that we need to fill these many places of the arrayValue, we slice the pasting array from 0 to the remaining places.
- Now we create the new array which is a merge of: arrayValue sliced from 0 to currentFocusedIndex and then the partially filled array i.e. the sliced-pasted array.
- The code for this looks like below:
const lastIndex = arrayValue.length - 1; if (currentFocusedIndex > 0) { const remainingPlaces = lastIndex - currentFocusedIndex; const partialArray = newArray.slice(0, remainingPlaces + 1); setArrayValue([ ...arrayValue.slice(0, currentFocusedIndex), ...partialArray ]); }
- If
currentFocusedIndex = 0
, then we do set the arrayValue array like below:
setArrayValue([ ...newArray, ...arrayValue.slice(newArray.length - 1, lastIndex) ]);
Once we update the arrayValue
field, now it is time that we update the currentFocusedIndex
. We update the currentFocusedIndex
and the input element based on the following condition:
- If the pasting array's length is less than the length of the arrayValue and the current focused index is the first input element then we update the focus of that input element which contains the last element of the pasting array.
- Else we update the focus of the last input element:
if (newArray.length < arrayValue.length && currentFocusedIndex === 0) {
setCurrentFocusedIndex(newArray.length - 1);
inputRefs.current[newArray.length - 1].focus();
} else {
setCurrentFocusedIndex(arrayValue.length - 1);
inputRefs.current[arrayValue.length - 1].focus();
}
We stitch all these changes inside the useEffect hook like below:
useEffect(() => {
document.addEventListener("paste", async () => {
// Handle all sub-usecases here
const pastePermission = await navigator.permissions.query({
name: "clipboard-read" as PermissionName
});
if (pastePermission.state === "denied") {
throw new Error("Not allowed to read clipboard");
}
const clipboardContent = await navigator.clipboard.readText();
try {
let newArray: Array<number | string> = clipboardContent.split("");
newArray = newArray.map((num) => Number(num));
const lastIndex = arrayValue.length - 1;
if (currentFocusedIndex > 0) {
const remainingPlaces = lastIndex - currentFocusedIndex;
const partialArray = newArray.slice(0, remainingPlaces + 1);
setArrayValue([
...arrayValue.slice(0, currentFocusedIndex),
...partialArray
]);
} else {
setArrayValue([
...newArray,
...arrayValue.slice(newArray.length - 1, lastIndex)
]);
}
if (newArray.length < arrayValue.length && currentFocusedIndex === 0) {
setCurrentFocusedIndex(newArray.length - 1);
inputRefs.current[newArray.length - 1].focus();
} else {
setCurrentFocusedIndex(arrayValue.length - 1);
inputRefs.current[arrayValue.length - 1].focus();
}
} catch (err) {
console.error(err);
}
});
return () => {
document.removeEventListener("paste", () =>
console.log("Removed paste listner")
);
};
}, [arrayValue, currentFocusedIndex]);
We have completed the implementation of our passcode component. Here is what the passcode component will look like:
const Passcode = () => {
const [arrayValue, setArrayValue] = useState<(string | number)[]>([
"",
"",
"",
""
]);
const [currentFocusedIndex, setCurrentFocusedIndex] = useState(0);
const inputRefs = useRef<Array<HTMLInputElement> | []>([]);
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
const keyCode = parseInt(e.key);
if (
!(keyCode >= 0 && keyCode <= 9) &&
e.key !== "Backspace" &&
!(e.metaKey && e.key === "v")
) {
e.preventDefault();
}
};
const onChange = (e: BaseSyntheticEvent, index: number) => {
setArrayValue((preValue: (string | number)[]) => {
const newArray = [...preValue];
if (parseInt(e.target.value)) {
newArray[index] = parseInt(e.target.value);
} else {
newArray[index] = e.target.value;
}
return newArray;
});
};
const onKeyUp = (e: KeyboardEvent<HTMLInputElement>, index: number) => {
if (e.key === "Backspace") {
if (index === 0) {
setCurrentFocusedIndex(0);
} else {
setCurrentFocusedIndex(index - 1);
if (inputRefs && inputRefs.current && index === currentFocusedIndex) {
inputRefs.current[index - 1].focus();
}
}
} else {
if (parseInt(e.key) && index < arrayValue.length - 1) {
setCurrentFocusedIndex(index + 1);
if (inputRefs && inputRefs.current && index === currentFocusedIndex) {
inputRefs.current[index + 1].focus();
}
}
}
};
const onFocus = (e: BaseSyntheticEvent, index: number) => {
setCurrentFocusedIndex(index);
// e.target.focus();
};
useEffect(() => {
document.addEventListener("paste", async () => {
// Handle all sub-usecases here
const pastePermission = await navigator.permissions.query({
name: "clipboard-read" as PermissionName
});
if (pastePermission.state === "denied") {
throw new Error("Not allowed to read clipboard");
}
const clipboardContent = await navigator.clipboard.readText();
try {
let newArray: Array<number | string> = clipboardContent.split("");
newArray = newArray.map((num) => Number(num));
const lastIndex = arrayValue.length - 1;
if (currentFocusedIndex > 0) {
const remainingPlaces = lastIndex - currentFocusedIndex;
const partialArray = newArray.slice(0, remainingPlaces + 1);
setArrayValue([
...arrayValue.slice(0, currentFocusedIndex),
...partialArray
]);
} else {
setArrayValue([
...newArray,
...arrayValue.slice(newArray.length - 1, lastIndex)
]);
}
if (newArray.length < arrayValue.length && currentFocusedIndex === 0) {
setCurrentFocusedIndex(newArray.length - 1);
inputRefs.current[newArray.length - 1].focus();
} else {
setCurrentFocusedIndex(arrayValue.length - 1);
inputRefs.current[arrayValue.length - 1].focus();
}
} catch (err) {
console.error(err);
}
});
return () => {
document.removeEventListener("paste", () =>
console.log("Removed paste listner")
);
};
}, [arrayValue, currentFocusedIndex]);
return (
<>
<div> currentFocusedIndex: {currentFocusedIndex}</div>
<div>{arrayValue}</div>
{arrayValue.map((value: string | number, index: number) => (
<input
key={`index-${index}`}
ref={(el) => el && (inputRefs.current[index] = el)}
inputMode="numeric"
maxLength={1}
type="text"
value={String(value)}
onChange={(e) => onChange(e, index)}
onKeyUp={(e) => onKeyUp(e, index)}
onKeyDown={(e) => onKeyDown(e)}
onFocus={(e) => onFocus(e, index)}
/>
))}
</>
);
};
Finally, you can import this component into your App.tsx file like below:
import "./styles.css";
import Passcode from "./components/Passcode";
export default function App() {
return (
<div className="App">
<h1>Passcode component</h1>
<Passcode />
</div>
);
}
Here is the working example of our Passcode component:
Summary
To wrap up, we have learned how to build a passcode component and solved some base case scenarios required for building this type of component.
If you would like to use this component, you can check out the library I made: react-headless-passcode. It's a headless library that allows you to pass the array value to the usePasscode
hook, which takes care of all the complex scenarios mentioned above and other scenarios as well. With headless, you can focus more on building your passcode UI and styling it however you like. The entire control is given to the developer. The job of react-headless-passcode is to provide you with all the logic you need to get the passcode component up and running.
Thank you for reading!
Top comments (18)
Just use normal input with letter-spacing and create a mask over it in CSS in whatever way. It will be simpler, and handle everything as it should.
It might will be a little bit not convenient to add border, radius , other styles and put a dash in the middle of the sentence.
Thanks for reading this article @lukeshiru. Yes the solution you provided is easier but create a mask is a bit tricker.
You can try out this library I build: react-headless-passcode. It provides a hook:
usePasscode
which makes it easier to use and you won't require to reinvent the wheelAdded a hacky example of input squares using inline-svg background.
VERY THOROUGH ARTICLE 💪 GOOD JOB!
Great article!
Cool article!
Love this article, keep up the good work!
Always encrypt passcode data before you store!
Just trying to save any devs out there preemptively.
This Looks quite helpful. Thanks for your interest and feedback on this article. Will surely try out in that way!
Definitely an interesting approach highlighting a lot of useful React concepts 👍
But I'm still on the "just use a single input" side of the debate and so, aparently, is Google: web.dev/sms-otp-form (it's also pretty useful to learn how to auto-suggest codes from SMS).
plus here's a recent article on how (and why) to implement it: dev.to/madsstoumann/using-a-single...
@boredcity thanks for the feedback. Yes, this looks a very good approach to solve the issue. Thanks for sharing this article!