React One Time Password Input Component
Last updated on

React One Time Password Input Component


I recently had an opportunity to create a one-time-password like component in a React app. The goal was to have a readonly separated, 6 digit/character code that can be displayed on one device and a separated input to enter that code in a web app.

The solution I arrived at is a 6-input component that advances the focus as the user types. This can also handle the “Backspace” key via the useKeyPressEvent hook from react-use.

This is the solution that worked for our case and I would love to hear others have solved this challenge as well.

What We’re Building

Parent Component

The component that is orchestrating the SeparatedInput component will need to have a state for the value and a handler to update that state with the value that SeparatedInput will pass back up. We can set it up like this:

OTPContainer.tsx
export function OTPContainer({}) {
const [value, setValue] = useState("");
const handleChange = (newValue: string) => {
// do validation work here
setValue(newValue);
};
return <SeparatedInput value={value} onChange={handleChange} />;
}

Rendering The Inputs

We’re going to start with a new component and name it something like SeparatedInput.tsx. This component will house our inputs and the handlers for them. We’ll be using styled-components to make the styling a bit easier.

SeparatedInput.tsx
export const InputContainer = styled.div`
display: flex;
justify-content: between;
gap: 2em;
max-width: 44em; // magic number, can be whatever fits the project's needs
`;
export function SeparatedInput({}) {
return <InputContainer>{/* we'll place the inputs here */}</InputContainer>;
}

We’ll need to pass our value to this component so let’s add that as a prop.

...
export function SeparatedInput({ value }) {}
...

Now we need to separate this value into an array that we can map into our inputs.

...
// can make this a passed prop if reused and more/fewer digits/characters are needed
const MAX_DIGITS = 6;
export function SeparatedInput({ value }: { value: string }) {
const separatedValue = useMemo(() => {
// fill the unused inputs with empty strings
const filler = Array(MAX_DIGITS - value.length).fill("");
const resolvedValue = [...value, ...filler];
// make sure it's only 6 characters long
resolvedValue.length = MAX_DIGITS;
return resolvedValue;
}, [value]);
...
}

We can spread our value into an array to create an array of characters. This is great if our value is 6 characters long but doesn’t quite work if it’s less than that. To fix this, we can fill out the rest with empty strings using Array(n).fill(""). We can then spread this into our array as well.

The line resolvedValue.length = MAX_DIGITS; trims any elements over our length of MAX_DIGITS (6).

From here, we can map over our array and render our inputs using those strings.

...
export const StyledInput = styled.input`
padding: 0.5em 0.25em;
font-size: 2.75em;
margin-bottom: 0.25em;
border: none;
border-bottom: 6px solid hsl(0, 0%, 20%);
background-color: hsl(0, 0%, 7%);
color: hsl(0, 0%, 90%);
width: calc(100% / 6);
text-align: center;
/* we use a serif font to help differentiate characters/digits */
font-family: serif;
`;
...
return (
<InputContainer>
{separatedValue.map((character, index) => {
return (
<StyledInput
key={index}
id={`${index}`}
value={character}
/>
);
})}
</InputContainer>
);

Handling Input Value Changes

We can use one function to pass to the inputs since each input will send an event on change. With this, we’ll be passing our onChange back up via a prop.

...
export function SeparatedInput({ value, onChange }: {
value: string;
onChange?: (value: string) => void;
}) {
...
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
onChange?.(value + event.target.value);
};
return (
<InputContainer>
{separatedValue.map((character, index) => {
return (
<StyledInput
key={index}
id={`${index}`}
value={character}
onChange={handleChange}
/>
);
})}
</InputContainer>
);
}

Great! This handler will take each unique event and add that to the value. We still have an issue with typing into the inputs since they don’t advance to the next input. This will cause the value to keep growing out of sync with the focussed input.

For the focus we can leverage useRef. We’ll get the ref of the InputContainer so we can control the focus of its children. We’ll also need some state to handle the focus index. We can then update the focus via useEffect on the update of focus index.

To set the focus index we can take the previous state and check to see if it’s less than the last input index and, if so, increment by one.

...
export function SeparatedInput({ value, onChange }: {
value: string;
onChange?: (value: string) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
// initial value of zero will focus the first input on load
const [focusIndex, setFocusIndex] = useState(0);
useEffect(() => {
// focus the current input by index
if (ref.current?.children[focusIndex]) {
(ref.current.children[focusIndex] as HTMLInputElement).focus();
}
}, [focusIndex, readOnly]);
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
// move the cursor forward after input
setFocusIndex((prev) =>
prev < MAX_DIGITS - 1 ? prev + 1 : MAX_DIGITS - 1
);
onChange?.(value + event.target.value);
};
...
return (
<InputContainer>
{separatedValue.map((character, index) => {
return (
<StyledInput
key={index}
id={`${index}`}
value={character}
onChange={handleChange}
/>
);
})}
</InputContainer>
);
}

Cool, this seems to work pretty well for adding characters to the inputs. The next thing we’ll need to do is handle removing characters using “Backspace.” To achieve this, we’ll use the useKeyPressEvent hook. This hook takes the keyPress key as the first argument and a callback function to do something when that key is pressed.

Let’s set up the callback function first. For our specific case we want the “Backspace” key remove the last character in our value. We can use slice on the value string to handle this. After removing the item, we’ll set the focus to the previous input.

Once our function is in place we can pass it to useKeyPressEvent.

import { useKeyPressEvent } from "react-use";
...
const handleBackspace = useCallback(() => {
// remove the last character
const newValue = value.slice(0, -1);
// move the cursor back one
setFocusIndex((prev) => (prev > 0 ? prev - 1 : 0));
onChange?.(newValue);
}, [onChange, value]);
// fires handleBackspace event when Backspace is pressed
useKeyPressEvent("Backspace", handleBackspace);
...

Now we can move back and forth by typing characters and “Backspace.”

We can still click into any input and the focus will shift to that one. However, if we type it will add the character to the next input in our sequence. There are few ways to improve this experience and I’ll share the one that is currently meeting our needs, though we may update it in the future.

The input sequence we’ll use is just forward and back, without the user clicking or tabbing/shift+tabbing into other inputs. Since it’s only six characters it shouldn’t be too bad to hit “Backspace” a few times rather than update a previous input via mouse click. If it were longer I would definitely opt for a solution where you can correct any input in the chain.

We can use the disabled attribute on the inputs to prevent the input from receiving focus. We’ll create a check to make sure that the inputs before and after our current focus index are disabled. We’ll also prevent it from disabling the last input if all values are filled.

return (
<InputContainer ref={ref}>
{separatedValue.map((character, index) => {
const disabled =
(index < value.length && index + 1 < MAX_DIGITS) ||
index > focusIndex;
return (
<StyledInput
key={index}
id={`${index}`}
value={character}
onChange={handleChange}
disabled={disabled}
/>
);
})}
</InputContainer>
);
}

Display the Code

This component will also be used to display the generated code to the user in the other device. In order to use this as a display component we’ll want to make sure the user can’t update the inputs. We’ll use a combination of disabled and readonly attributes.

The parent can pass the value and the readOnly props to the SeparatedInput component.

OTPDisplayContainer.tsx
export function OTPDisplayContainer({}) {
const value = useGetValueFromService();
return <SeparatedInput value={value} readOnly />;
}

The SeparatedInput will then use this to check for in the handlers and inputs to make sure it will only show the value, not change it. The final component code will then be as follows.

import React, {
ChangeEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useKeyPressEvent } from "react-use";
import { InputContainer, StyledInput } from "./styledComponents";
// can make this a passed prop if more/fewer digits/characters are needed
const MAX_DIGITS = 6;
export function SeparatedInput({
value,
onChange,
readOnly,
}: {
value: string;
onChange?: (value: string) => void;
readOnly: boolean;
}) {
const ref = useRef<HTMLDivElement>(null);
const [focusIndex, setFocusIndex] = useState(0);
const handleBackspace = useCallback(() => {
if (readOnly) {
return;
}
// remove the last character
const newValue = value.slice(0, -1);
// move the cursor back one
setFocusIndex((prev) => (prev > 0 ? prev - 1 : 0));
onChange?.(newValue);
}, [onChange, readOnly, value]);
// fires handleBackspace event when Backspace is pressed
useKeyPressEvent("Backspace", handleBackspace);
useEffect(() => {
// focus the current input by index
if (ref.current?.children[focusIndex] && !readOnly) {
(ref.current.children[focusIndex] as HTMLInputElement).focus();
}
}, [focusIndex, readOnly]);
const separatedValue = useMemo(() => {
// fill the unused inputs with empty strings
const filler = Array(MAX_DIGITS - value.length).fill("");
const resolvedValue = [...value, ...filler];
// make sure it's only 6 characters long
resolvedValue.length = MAX_DIGITS;
return resolvedValue;
}, [value]);
const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
if (readOnly) {
return;
}
// move the cursor forward after input
setFocusIndex((prev) =>
prev < MAX_DIGITS - 1 ? prev + 1 : MAX_DIGITS - 1,
);
onChange?.(value + event.target.value);
},
[onChange, readOnly, value],
);
return (
<InputContainer ref={ref}>
{separatedValue.map((character, index) => {
const disabled =
readOnly ||
(index < value.length && index + 1 < MAX_DIGITS) ||
index > focusIndex;
return (
<StyledInput
key={index}
id={`${index}`}
value={character}
onChange={handleChange}
readOnly={readOnly}
disabled={disabled}
/>
);
})}
</InputContainer>
);
}

Category: dev