DEV Community

Neeraj Singh
Neeraj Singh

Posted on

Building a Custom OTP Input Component in React Native

Introduction

One-time password (OTP) verification has become essential in mobile applications for secure user authentication. While several libraries offer OTP components but i think we can create easily with our custom components. This article guides you through creating a custom, animated OTP Input component in React Native that offers a sleek, user-friendly interface.

Features of Our OTP Component

  • Smooth Cursor Blink Animation
  • Shake Animation for Invalid OTP
  • Auto-Focus & Smart Backspace Navigation
  • Countdown Timer with Resend OTP Feature
  • Native-Looking Input Boxes Without External UI Libraries
  • Easy Integration with Any Parent Screen

Component Goals

Our OTP component aims to be:

  • Modern in Appearance: Rounded input boxes with smooth transitions.
  • Intelligent in Behavior: Auto-focus on the next input and manageable state flows.
  • Engaging with Micro-Animations: Cursor blinking, box-scale on focus, and shake effects for error notifications.
  • Reusable: Easy to incorporate with simple prop configurations.
  • Cross-Compatible: Works on both iOS and Android, supporting light and dark themes.

Building the OTP Input Component

Component Structure

The OTP component utilizes:

  • TextInput for each digit box
  • Animated.Value for various animations
  • Hooks like useEffect, useRef, and useImperativeHandle

Core Functionalities

1. Cursor Blink Animation

The cursor is simulated with a small vertical bar using opacity animation. This enhances the UX by only showing the cursor for the active input.

Animated.loop(
  Animated.sequence([
    Animated.timing(cursorOpacity, { toValue: 0, duration: 450 }),
    Animated.timing(cursorOpacity, { toValue: 1, duration: 450 }),
  ])
).start();
Enter fullscreen mode Exit fullscreen mode

2. Shake Animation on Errors

To indicate an invalid OTP, trigger a shake animation:

otpRef.current?.triggerShake();
Enter fullscreen mode Exit fullscreen mode

The shake uses an interpolated translateX to create an effective feedback mechanism.

translateX: shakeAnim.interpolate({ inputRange: [-1, 1], outputRange: [-10, 10] })
Enter fullscreen mode Exit fullscreen mode

3. Countdown Timer with Resend OTP

A built-in timer starts at 60 seconds, allowing for a seamless resend experience:

useEffect(() => {
  if (countdown > 0) {
    setInterval(() => setCountdown(prev => prev - 1), 1000);
  }
});
Enter fullscreen mode Exit fullscreen mode

4. Auto-Focus & Smart Navigation

The input fields are designed for intuitive navigation. Typing in one box automatically shifts focus to the next, while backspacing on an empty box moves the focus to the previous input.

5. Box Scale Animation

Every focused input box features a subtle scaling effect, enhancing perceived interactivity:

transform: [{ scale: animatedValues.current[index].interpolate({...}) }]
Enter fullscreen mode Exit fullscreen mode

Integrating the OTP Component

Here’s how to integrate the OTPInputComponent into your parent screen:

const [otp, setOtp] = useState(['', '', '', '']); // if we use lenght 4 then 4 empyt string

const submitOtp = () => {
  console.log('OTP entered:', otp);
};

<OTPInputComponent
  length={4} // it is dynamic length
  value={otp}
  onChange={(code) => setOtp(code)}
/>
<Button title="Submit" onPress={submitOtp} />
Enter fullscreen mode Exit fullscreen mode

Complete OTP Component Code

Here is the comprehensive code for the OTPInputComponent component:

import React, { useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react';
import { View, Text, TextInput, TouchableOpacity, Animated, StyleSheet, Dimensions } from 'react-native';

const { width } = Dimensions.get('window');

interface InputProps {
  value: string[];
  onChange: (value: string[]) => void;
  length?: number;
  disabled?: boolean;
  onResendOTP?: () => void;
}

export const OTPInputComponent = forwardRef(({ value, onChange, length = 5, disabled = false, onResendOTP }: InputProps, ref) => {
  const inputRefs = useRef<TextInput[]>([]);
  const animatedValues = useRef<Animated.Value[]>([]);
  const [countdown, setCountdown] = useState(60);
  const [isResendActive, setIsResendActive] = useState(false);
  const cursorOpacity = useRef(new Animated.Value(1)).current;
  const shakeAnim = useRef(new Animated.Value(0)).current;

  useImperativeHandle(ref, () => ({ triggerShake }));

  useEffect(() => {
    Animated.loop(Animated.sequence([
      Animated.timing(cursorOpacity, { toValue: 0, duration: 450 }),
      Animated.timing(cursorOpacity, { toValue: 1, duration: 450 }),
    ])).start();
  }, [cursorOpacity]);

  useEffect(() => {
    animatedValues.current = Array(length).fill(0).map(() => new Animated.Value(0));
  }, [length]);

  useEffect(() => {
    const timer = countdown > 0 && !isResendActive ? setInterval(() => setCountdown(prev => prev - 1), 1000) : null;
    if (countdown === 0) setIsResendActive(true);
    return () => timer && clearInterval(timer);
  }, [countdown, isResendActive]);

  const triggerShake = () => {
    shakeAnim.setValue(0);
    Animated.sequence([
      Animated.timing(shakeAnim, { toValue: 1, duration: 80, useNativeDriver: true }),
      Animated.timing(shakeAnim, { toValue: -1, duration: 80, useNativeDriver: true }),
      Animated.timing(shakeAnim, { toValue: 1, duration: 80, useNativeDriver: true }),
      Animated.timing(shakeAnim, { toValue: 0, duration: 80, useNativeDriver: true }),
    ]).start();
  };

  const handleChange = (text: string, index: number) => {
    const newValue = [...value];
    newValue[index] = text;
    onChange(newValue);
    if (text && index < length - 1) inputRefs.current[index + 1].focus();
  };

  const handleResendOTP = () => {
    if (isResendActive && onResendOTP) {
      onResendOTP();
      setCountdown(60);
      setIsResendActive(false);
    }
  };

  return (
    <View style={styles.mainContainer}>
      <Animated.View style={[styles.container, { transform: [{ translateX: shakeAnim.interpolate({ inputRange: [-1, 1], outputRange: [-10, 10] }) }] }]}>
        {Array(length).fill(0).map((_, index) => (
          <View key={index} style={styles.inputContainer}>
            <TextInput
              ref={ref => (inputRefs.current[index] = ref)}
              style={styles.input}
              maxLength={1}
              keyboardType="number-pad"
              onChangeText={text => handleChange(text, index)}
              value={value[index]}
              editable={!disabled}
            />
            {index === 0 && <Animated.View style={[styles.cursor, { opacity: cursorOpacity }]} />}
          </View>
        ))}
      </Animated.View>
      <TouchableOpacity onPress={handleResendOTP} disabled={!isResendActive}>
        <Text style={styles.resendText}>{isResendActive ? 'Resend OTP' : `Resend OTP in ${countdown}s`}</Text>
      </TouchableOpacity>
    </View>
  );
});

const styles = StyleSheet.create({
  // Define your styles here...
});

Enter fullscreen mode Exit fullscreen mode

Conclusion

This OTP input component exemplifies the fusion of functionality, reusability, and modern design practices.

Feel free to use the code and adapt it for your application needs!

Screenshot

example-screenshot-otpscreen

Top comments (0)