DEV Community

Ajmal Hasan
Ajmal Hasan

Posted on

Building a Reusable OTP Input Component in React Native

One-Time Passwords (OTPs) have become a standard in two-factor authentication. Here's a ready-to-use, customizable OTP input component for your React Native application that includes validation, animations, and a resend timer.

Image description

Features

  • Customizable length (default: 5 digits)
  • Auto-focus to next input on entry
  • Focus previous input on backspace
  • Animation feedback on input focus
  • Built-in countdown timer for OTP resend
  • Fully typed with TypeScript
  • Works with React Native Unistyles

Component Code

Note: You can use react native StyleSheet also instead of react-native-unistyles.

import React, { useEffect, useRef, useState } from 'react';
import { View, TextInput, Animated, TouchableOpacity } from 'react-native';
import { createStyleSheet, useStyles } from 'react-native-unistyles';
import { boxShadow, hpx, wpx } from '@utils/Scaling';
import CustomText from './CustomText';
import { FONTS } from '@constants/Fonts';

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

export const OTPInput: React.FC<OTPInputProps> = ({
    value,
    onChange,
    length = 5,
    disabled = false,
    onResendOTP,
}) => {
    const { styles, theme } = useStyles(stylesheet);
    const inputRefs = useRef<TextInput[]>([]);
    const animatedValues = useRef<Animated.Value[]>([]);
    const [countdown, setCountdown] = useState(60);
    const [isResendActive, setIsResendActive] = useState(false);

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

    // Countdown timer
    useEffect(() => {
        let timer: NodeJS.Timeout;
        if (countdown > 0 && !isResendActive) {
            timer = setInterval(() => {
                setCountdown((prev) => prev - 1);
            }, 1000);
        } else if (countdown === 0) {
            setIsResendActive(true);
        }
        return () => {
            if (timer) clearInterval(timer);
        };
    }, [countdown, isResendActive]);

    const handleResendOTP = () => {
        if (isResendActive && onResendOTP) {
            onResendOTP();
            setCountdown(60);
            setIsResendActive(false);
            // Focus on first input after a small delay to ensure state is updated
            setTimeout(() => {
                focusInput(0);
            }, 50);
        }
    };

    const focusInput = (index: number) => {
        if (inputRefs.current[index]) {
            inputRefs.current[index].focus();

            // Trigger animation
            Animated.sequence([
                Animated.timing(animatedValues.current[index], {
                    toValue: 1,
                    duration: 100,
                    useNativeDriver: true,
                }),
                Animated.timing(animatedValues.current[index], {
                    toValue: 0,
                    duration: 100,
                    useNativeDriver: true,
                }),
            ]).start();
        }
    };

    const handleChange = (text: string, index: number) => {
        const newValue = [...value];
        newValue[index] = text;
        onChange(newValue);

        if (text && index < length - 1) {
            focusInput(index + 1);
        }
    };

    const handleKeyPress = (event: any, index: number) => {
        if (event.nativeEvent.key === 'Backspace' && !value[index] && index > 0) {
            focusInput(index - 1);
        }
    };

    return (
        <View style={styles.mainContainer}>
            <View style={styles.container}>
                {Array(length)
                    .fill(0)
                    .map((_, index) => {
                        const animatedStyle = {
                            transform: [
                                {
                                    scale: animatedValues.current[index]?.interpolate({
                                        inputRange: [0, 0.5, 1],
                                        outputRange: [1, 1.1, 1],
                                    }) || 1,
                                },
                            ],
                        };

                        return (
                            <Animated.View key={index} style={[styles.inputContainer, animatedStyle]}>
                                <TextInput
                                    ref={(ref) => {
                                        if (ref) inputRefs.current[index] = ref;
                                    }}
                                    style={[
                                        styles.input,
                                        value[index] ? styles.filledInput : {},
                                    ]}
                                    maxLength={1}
                                    keyboardType="number-pad"
                                    onChangeText={(text) => handleChange(text, index)}
                                    onKeyPress={(event) => handleKeyPress(event, index)}
                                    value={value[index]}
                                    editable={!disabled}
                                    selectTextOnFocus
                                    placeholder="●"
                                    placeholderTextColor={theme.colors.secondaryText}
                                />
                            </Animated.View>
                        );
                    })}
            </View>
            <TouchableOpacity
                onPress={handleResendOTP}
                disabled={!isResendActive}
                style={styles.resendContainer}
            >
                <CustomText
                    variant="sm"
                    style={{
                        color: theme.colors.navyBlueLine,
                        fontFamily: FONTS.SemiBold,
                        opacity: isResendActive ? 1 : 0.5
                    }}
                >
                    {isResendActive ? 'Resend OTP' : `Resend OTP in ${countdown}s`}
                </CustomText>
            </TouchableOpacity>
        </View>
    );
};

const stylesheet = createStyleSheet(({ colors }) => ({
    mainContainer: {
        width: '100%',
    },
    container: {
        flexDirection: 'row',
        justifyContent: 'space-between',
        alignItems: 'center',
        width: '100%',
        marginVertical: hpx(20),
    },
    inputContainer: {
        width: wpx(66),
        height: hpx(80),
        ...boxShadow.light,
    },
    input: {
        width: '100%',
        height: '100%',
        borderRadius: 10,
        backgroundColor: colors.white,
        textAlign: 'center',
        fontSize: 24,
        fontWeight: '600',
        color: colors.typography,
    },
    filledInput: {
        backgroundColor: colors.white,
        borderColor: colors.primary,
    },
    resendContainer: {
        alignItems: 'center',
    },
}));
Enter fullscreen mode Exit fullscreen mode

Usage Example

Here's how to implement the OTP component in your screen:

import React, { useState } from 'react';
import { View } from 'react-native';
import { OTPInput } from './components/OTPInput';
import CustomText from './components/CustomText';

const OTPScreen = ({ navigation }) => {
    const [otpValues, setOtpValues] = useState(["", "", "", "", ""]);
    const [otpError, setOtpError] = useState(null);

    const handleOTPChange = (newValues) => {
        setOtpValues(newValues);
        setOtpError(null);
    };

    const handleResendOTP = () => {
        // Reset OTP values
        setOtpValues(["", "", "", "", ""]);
        setOtpError(null);
        // TODO: Add your API call to resend OTP here
    };

    const handleConfirm = () => {
        const otp = otpValues.join('');
        if (otp.length !== 5) {
            setOtpError('Please enter a complete OTP');
            return;
        }
        // Handle OTP verification here
        navigation.navigate('Home'); // Replace with your navigation logic
    };

    return (
        <View style={styles.container}>
            <View style={styles.formContainer}>
                <OTPInput
                    value={otpValues}
                    onChange={handleOTPChange}
                    length={5}
                    onResendOTP={handleResendOTP}
                />
                {otpError && (
                    <CustomText style={styles.errorText} variant="sm">{otpError}</CustomText>
                )}
            </View>

            {/* Add your button to submit OTP */}
            <Button title="Confirm" onPress={handleConfirm} />
        </View>
    );
};

const styles = StyleSheet.create({
    container: {
        flex: 1,
        padding: 20,
    },
    formContainer: {
        marginBottom: 20,
    },
    errorText: {
        color: 'red',
        textAlign: 'center',
        marginTop: 10,
    },
});

export default OTPScreen;
Enter fullscreen mode Exit fullscreen mode

Customization

You can customize:

  • Number of OTP fields by changing the length prop
  • Countdown duration (default: 60s) by modifying the initial state
  • Styling through the stylesheet
  • Disable the component with the disabled prop

Dependencies

  • React Native
  • React Native Unistyles
  • You'll need to create/modify:
    • CustomText component
    • boxShadow, hpx, and wpx utility functions
    • FONTS constant

Conclusion

This component provides a complete solution for OTP input in React Native applications with proper focus management, animations, and a resend timer. It's designed to be easily integrated into your authentication flow with minimal setup.

Happy coding!

Top comments (1)

Collapse
 
teeo28 profile image
Developer TeeO

Awesome work, You did excellently!