DEV Community

Ajmal Hasan
Ajmal Hasan

Posted on

3 1 1 1 2

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!

Tiugo image

Modular, Fast, and Built for Developers

CKEditor 5 gives you full control over your editing experience. A modular architecture means you get high performance, fewer re-renders and a setup that scales with your needs.

Start now

Top comments (0)

👋 Kindness is contagious

Engage with a wealth of insights in this thoughtful article, valued within the supportive DEV Community. Coders of every background are welcome to join in and add to our collective wisdom.

A sincere "thank you" often brightens someone’s day. Share your gratitude in the comments below!

On DEV, the act of sharing knowledge eases our journey and fortifies our community ties. Found value in this? A quick thank you to the author can make a significant impact.

Okay