Make a snazzy one-time code input in React Native

If you’ve used a mobile app in the past few years, odds are you’ve used your phone number to sign into an account. It’s a familiar process: enter your number, receive an SMS with a one-time code, and enter that code to sign in.

We can make this experience a little nicer for users by rendering a code input that is tailored to accept and display four to six digits.

Four narrow inputs placed in a row. Each input accepts one number. The numpad keyboard is also displayed.

Unfortunately, we can’t create this UI with a single TextInput. Our strategy will be to capture the user’s input with a hidden TextInput and display the code with separate components.

Let’s start with the skeleton of our component. We’ll ignore the usual imports from React and React Native as well as the export of the component for the purposes of this post.

const CODE_LENGTH = 4;

const CodeInput = () => {
  return (
    <SafeAreaView style={style.container}>
    </SafeAreaView>
  );
};

const style = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
});

Now we can add a hidden TextInput to capture the code from the user as well as some state to hold the code.

const CodeInput = () => {
  const [code, setCode] = useState('');

  return (
    <SafeAreaView style={style.container}>
      <TextInput
        value={code}
        onChangeText={setCode}
        keyboardType="number-pad"
        returnKeyType="done"
        textContentType="oneTimeCode"
        maxLength={CODE_LENGTH}
        style={style.hiddenCodeInput}
      />
    </SafeAreaView>
  );
};

const style = StyleSheet.create({

  ...

  hiddenCodeInput: {
    position: 'absolute',
    height: 0,
    width: 0,
    opacity: 0,
  },
});

Next we need to display the code entered by the user and focus the hidden TextInput when the code is pressed.

To display the code digits, we can map over an array of the code length. For each array element, we’ll key into the code string with the index. If there is no character at the current index (if the code is too short), we’ll render an empty space instead.

const CODE_LENGTH = 4;

const CodeInput = () => {
  const codeDigitsArray = new Array(CODE_LENGTH).fill(0);

  const toDigitInput = (_value: number, idx: number) => {
    const emptyInputChar = ' ';
    const digit = code[idx] || emptyInputChar;

    return (
      <View key={idx}>
        <Text>{digit}</Text>
      </View>
    );
  };

  return (
    <SafeAreaView style={style.container}>
      <Pressable style={style.inputsContainer}>
        {codeDigitsArray.map(toDigitInput)}
      </Pressable>
      <TextInput
        value={code}
        onChangeText={setCode}
        keyboardType="number-pad"
        returnKeyType="done"
        textContentType="oneTimeCode"
        maxLength={CODE_LENGTH}
        style={style.hiddenCodeInput}
      />
    </SafeAreaView>
  );
};

...

Next, we want to focus the hidden TextInput when the user presses the code. To imperatively focus the TextInput, we will need to use a ref.

const ref = useRef<TextInput>(null);

const handleOnPress = () => {
  ref?.current?.focus();
};

...

<SafeAreaView style={style.container}>
  <Pressable style={style.inputsContainer} onPress={handleOnPress}>
    {codeDigitsArray.map(toDigitInput)}
  </Pressable>
  <TextInput
    ref={ref}
    value={code}
    onChangeText={setCode}
    keyboardType="number-pad"
    returnKeyType="done"
    textContentType="oneTimeCode"
    maxLength={CODE_LENGTH}
    style={style.hiddenCodeInput}
  />
</SafeAreaView>

...

Now our code input is fully functional!

Next, we’ll style the code input as a whole and the individual digits. Then we can add some conditional styling to highlight the current digit.

const toDigitInput = (_value: number, idx: number) => {
  const emptyInputChar = ' ';
  const digit = code[idx] || emptyInputChar;

  return (
    <View key={idx} style={style.inputsContainer}>
      <Text style={style.inputText}>{digit}</Text>
    </View>
  );
};

...

<SafeAreaView style={style.container}>
  <Pressable style={style.inputsContainer} onPress={handleOnPress}>
    {codeDigitsArray.map(toDigitInput)}
  </Pressable>
  <TextInput
    ref={ref}
    value={code}
    onChangeText={setCode}
    keyboardType="number-pad"
    returnKeyType="done"
    textContentType="oneTimeCode"
    maxLength={CODE_LENGTH}
    style={style.hiddenCodeInput}
  />
</SafeAreaView>

...

const style = StyleSheet.create({

  ...

  inputsContainer: {
    width: '60%',
    flexDirection: 'row',
    justifyContent: 'space-between',
  },
  inputContainer: {
    borderColor: '#cccccc',
    borderWidth: 2,
    borderRadius: 4,
    padding: 12,
  },
  inputText: {
    fontSize: 24,
    fontFamily: 'Menlo-Regular',
  },

  ...

});

Time for some conditional styling.

First, we’ll introduce some state to hold the focus state, which we will toggle when the user focuses and blurs the input. Finally, we’ll apply some styling to highlight the current digit.

The following must be true for the current digit to be highlighted:

  • the code input as a whole is focused by the user

containerIsFocused === true

AND

  • the index of the digit is equal to the current length of the inputted code

const isCurrentDigit = idx === code.length;

OR

  • the code is full and it’s the last digit
const isLastDigit = idx === CODE_LENGTH - 1;
const isCodeFull = code.length === CODE_LENGTH;

Applied to the component, the final styling logic looks like this:

const CODE_LENGTH = 4;

const CodeInput = () => {

  ...

  const [containerIsFocused, setContainerIsFocused] = useState(false);

  const handleOnPress = () => {
    setContainerIsFocused(true);
    ref?.current?.focus();
  };

  const handleOnBlur = () => {
    setContainerIsFocused(false);
  };

  ...

  const toDigitInput = (_value: number, idx: number) => {
    const emptyInputChar = ' ';
    const digit = code[idx] || emptyInputChar;

    const isCurrentDigit = idx === code.length;
    const isLastDigit = idx === CODE_LENGTH - 1;
    const isCodeFull = code.length === CODE_LENGTH;

    const isFocused = isCurrentDigit || (isLastDigit && isCodeFull);

    const containerStyle =
      containerIsFocused && isFocused
        ? {...style.inputContainer, ...style.inputContainerFocused}
        : style.inputContainer;

    return (
      <View key={idx} style={containerStyle}>
        <Text style={style.inputText}>{digit}</Text>
      </View>
    );
  };

  return (

      ...

      <TextInput
        ref={ref}
        value={code}
        onChangeText={setCode}
        onSubmitEditing={handleOnBlur}
        keyboardType="number-pad"
        returnKeyType="done"
        textContentType="oneTimeCode"
        maxLength={CODE_LENGTH}
        style={style.hiddenCodeInput}
      />

      ...

  )

  ...

};

That’s it! Here’s the GitHub repo for the code.

Four narrow inputs placed in a row. Each input accepts one number. The numpad keyboard is also displayed.