Otp Input
An animated otp-input with auto focus
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimatedCopy and paste the following code into your project.
component/base/otp-input.tsx
import React, { createContext, memo, useContext, useEffect, useRef, useState, type FC, type FunctionComponent,} from "react";import { Dimensions, Keyboard, Pressable, StyleSheet, TextInput, View, type ViewStyle,} from "react-native";import Animated, { useSharedValue, useAnimatedStyle, withTiming, withSequence, withSpring, FadeInDown, FadeOutDown, LinearTransition, interpolateColor,} from "react-native-reanimated";import type { IOtpInput, IOtpContext, IOtpItem } from "./types";import { ANIMATION_VARIATIONS } from "./const";const { width: WIDTH } = Dimensions.get("window");const OtpContext = createContext<IOtpContext>({} as IOtpContext);const OtpItem: FC<IOtpItem> & FunctionComponent<IOtpItem> = ({ index,}: IOtpItem): | (React.ReactNode & React.JSX.Element & React.ReactElement) | null => { const { inputRef, onPress, otpValue, onFocusNext, onFocusPrevious, setFocus, setOtpValue, focus, autoFocus, containerStyle, otpInputStyle, textStyle, otpCount = 6, editable, error, rest, inputBorderRadius, inputHeight, inputWidth, animationVariant = "fadeSlideDown", focusedBackgroundColor = "#0f0f23", unfocusedBackgroundColor = "#1a1a2e", focusedBorderColor = "rgba(248, 250, 252, 1)", unfocusedBorderColor = "rgba(248, 250, 252, 0.3)", errorBackgroundColor = "#1c0a0a", errorBorderColor = "#ef4444", }: IOtpContext = useContext<IOtpContext>(OtpContext); const borderWidth = useSharedValue<number>(focus === index ? 2 : 1); const inputScale = useSharedValue<number>(1); const focusProgress = useSharedValue<number>(focus === index ? 1 : 0); const errorProgress = useSharedValue<number>(error ? 1 : 0); const animations = ANIMATION_VARIATIONS[animationVariant]; useEffect(() => { borderWidth.value = withTiming<number>(focus === index ? 2 : 1, { duration: 1200, }); focusProgress.value = withTiming<number>(focus === index ? 1 : 0, { duration: 200, }); if (focus === index) { inputScale.value = withSequence<number>( withSpring(1.05, { damping: 50, stiffness: 120, mass: 0.5 }), withSpring(1, { damping: 50, stiffness: 120, mass: 0.5 }), ); } }, [focus]); useEffect(() => { errorProgress.value = withTiming<number>(error ? 1 : 0, { duration: 200, }); }, [error]); useEffect(() => { if (otpValue) { if ((otpValue[index]?.length ?? 0) > 1) { const format = otpValue[index]?.substring(0, otpCount); const numbers = format?.split("") ?? []; setOtpValue(numbers); setFocus(-1); Keyboard.dismiss(); } } }, [otpValue]); const animatedInputStyle = useAnimatedStyle< Pick< ViewStyle, "borderWidth" | "borderColor" | "backgroundColor" | "transform" > >(() => { const normalBg = interpolateColor( focusProgress.value, [0, 1], [unfocusedBackgroundColor, focusedBackgroundColor], ); const backgroundColor = interpolateColor( errorProgress.value, [0, 1], [normalBg, errorBackgroundColor], ); const normalBorder = interpolateColor( focusProgress.value, [0, 1], [unfocusedBorderColor, focusedBorderColor], ); const borderColor = interpolateColor( errorProgress.value, [0, 1], [normalBorder, errorBorderColor], ); return { borderWidth: borderWidth.value, borderColor, backgroundColor, transform: [{ scale: inputScale.value }], }; }); const getTextStyle = () => { if (error) { return [styles.text, styles.textError, textStyle]; } return [styles.text, textStyle]; }; return ( <View key={index} style={[containerStyle]}> <TextInput style={[ styles.inputSize, otpInputStyle, { color: "transparent", width: inputWidth, height: inputHeight, borderRadius: inputBorderRadius, }, ]} caretHidden keyboardType="number-pad" ref={inputRef.current[index]} value={otpValue[index]} onChangeText={(v) => onFocusNext(v, index)} onKeyPress={(e) => onFocusPrevious(e.nativeEvent.key, index)} textContentType="oneTimeCode" autoFocus={autoFocus && index === 0} {...rest} /> <Pressable disabled={!editable} onPress={onPress} style={styles.overlay}> <Animated.View layout={LinearTransition.springify()} style={[ styles.input, styles.inputSize, { width: inputWidth, height: inputHeight, borderRadius: inputBorderRadius, }, animatedInputStyle, ]} > {otpValue[index] !== "" && ( <Animated.Text entering={animations.entering} exiting={animations.exiting} style={getTextStyle()} > {otpValue[index]} </Animated.Text> )} </Animated.View> </Pressable> </View> );};export const OtpInput: FC<IOtpInput> & FunctionComponent<IOtpInput> = memo<IOtpInput>( ({ otpCount = 6, containerStyle = {}, otpInputStyle = {}, textStyle = {}, focusedColor = "#f8fafc", editable = true, enteringAnimated = FadeInDown, exitingAnimated = FadeOutDown, onInputFinished, onInputChange, error = false, errorMessage = "Invalid OTP. Please try again.", inputBorderRadius = 20, inputWidth = 60, inputHeight = 60, animationVariant = "fadeSlideDown", focusedBackgroundColor = "#0f0f23", unfocusedBackgroundColor = "#1a1a2e", focusedBorderColor = "rgba(248, 250, 252, 1)", unfocusedBorderColor = "rgba(248, 250, 252, 0.3)", errorBackgroundColor = "#1c0a0a", errorBorderColor = "#ef4444", ...rest }: IOtpInput): | (React.ReactNode & React.JSX.Element & React.ReactElement) | null => { const inputRef = useRef<any[]>([]); const data: string[] = new Array(otpCount).fill(""); inputRef.current = data.map( (_, index) => (inputRef.current[index] = React.createRef<TextInput>()), ); const [focus, setFocus] = useState<number>(0); const [otpValue, setOtpValue] = useState<string[]>(data); const opacity = useSharedValue<number>(1); const translateX = useSharedValue<number>(0); const onPress = () => { if (focus === -1) { setFocus(otpCount - 1); otpValue[data.length - 1] = ""; setOtpValue([...otpValue]); inputRef.current[data.length - 1].current.focus(); } else { inputRef.current[focus].current.focus(); } }; const onFocusNext = <V extends string, I extends number>( value: V, index: I, ) => { if (index < data.length - 1 && value) { inputRef.current[index + 1].current.focus(); setFocus(index + 1); } if (index === data.length - 1) { setFocus(-1); inputRef.current[index].current.blur(); } otpValue[index] = value; setOtpValue([...otpValue]); }; const onFocusPrevious = <K extends string, I extends number>( key: K, index: I, ) => { if (key === "Backspace" && index !== 0) { inputRef.current[index - 1].current.focus(); setFocus(index - 1); otpValue[index - 1] = ""; setOtpValue([...otpValue]); } else if (key === "Backspace" && index === 0) { otpValue[0] = ""; } }; if (otpCount < 4 || otpCount > 6) { throw new Error("OTP Count min is 4 and max is 6"); } const animatedContainerStyle = useAnimatedStyle< Pick<ViewStyle, "opacity" | "transform"> >(() => ({ opacity: opacity.value, transform: [{ translateX: translateX.value }], })); const triggerCompleteAnimation = () => { opacity.value = withSequence<number>( withTiming(0.6, { duration: 900 }), withTiming(1, { duration: 900 }), ); }; const triggerShakeAnimation = () => { translateX.value = withSequence<number>( withTiming(-4, { duration: 50 }), withTiming(4, { duration: 50 }), withTiming(-3, { duration: 50 }), withTiming(3, { duration: 50 }), withTiming(-2, { duration: 50 }), withTiming(2, { duration: 50 }), withTiming(0, { duration: 50 }), ); }; const inputProps: IOtpContext = { inputRef, otpValue, onPress, onFocusNext, onFocusPrevious, setFocus, setOtpValue, focus, containerStyle, otpInputStyle, textStyle, focusedColor, otpCount, editable, enteringAnimated, exitingAnimated, error, inputBorderRadius, inputWidth, inputHeight, animationVariant, focusedBackgroundColor, unfocusedBackgroundColor, focusedBorderColor, unfocusedBorderColor, errorBackgroundColor, errorBorderColor, ...rest, }; useEffect(() => { onInputChange?.(otpValue?.join("")); if ( otpValue && otpValue.join("").length === otpCount && onInputFinished ) { if (!error) { triggerCompleteAnimation(); } onInputFinished(otpValue.join("")); } }, [otpValue]); useEffect(() => { if (error) { triggerShakeAnimation(); const timeout = setTimeout(() => { otpValue.fill(""); setOtpValue([...otpValue]); setFocus(0); inputRef.current[0].current.focus(); }, 1000); return () => clearTimeout(timeout); } }, [error]); return ( <OtpContext.Provider value={inputProps}> <Animated.View style={[styles.container]}> <Animated.View style={[styles.row, animatedContainerStyle]}> {data.map((_, i) => ( <OtpItem key={i} index={i} /> ))} </Animated.View> {error && errorMessage && ( <Animated.Text entering={FadeInDown.duration(200)} exiting={FadeOutDown.duration(200)} style={styles.errorMessage} > {errorMessage} </Animated.Text> )} </Animated.View> </OtpContext.Provider> ); }, );export default memo<FC<IOtpInput> & FunctionComponent<IOtpInput>>(OtpInput);const styles = StyleSheet.create({ container: { width: WIDTH, height: 120, }, row: { flexDirection: "row", alignItems: "center", justifyContent: "center", }, inputSize: { marginHorizontal: 6, }, input: { alignItems: "center", justifyContent: "center", backgroundColor: "#1a1a2e", borderWidth: 1, borderColor: "rgba(248, 250, 252, 0.2)", }, inputFocused: { borderColor: "#f8fafc", backgroundColor: "#0f0f23", }, inputError: { borderColor: "#ef4444", backgroundColor: "#1c0a0a", }, text: { fontWeight: "600", fontSize: 18, color: "#f8fafc", }, textError: { color: "#fecaca", }, overlay: { position: "absolute", }, errorMessage: { color: "#ef4444", fontSize: 14, fontWeight: "500", textAlign: "center", marginTop: 20, paddingHorizontal: 16, },});Usage
import { View, Text, StyleSheet } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import { useFonts } from "expo-font";import { useState } from "react";import { SymbolView } from "expo-symbols";import OtpInput from "@/components/base/otp-input";import { FadeIn, FadeInUp, LinearTransition } from "react-native-reanimated";const wait = async <T extends number>(ms: T): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));export default function App() { const [fontLoaded] = useFonts({ SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"), HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"), }); const [code, setCode] = useState<string>(""); const [error, setError] = useState<boolean>(false); return ( <GestureHandlerRootView style={styles.container}> <StatusBar style="light" /> <View style={styles.content}> <View style={styles.iconBox}> <SymbolView name="lock.fill" size={24} tintColor="#fff" /> </View> <Text style={[ styles.title, fontLoaded && { fontFamily: "HelveticaNowDisplay" }, ]} > Verification </Text> <Text style={[ styles.subtitle, fontLoaded && { fontFamily: "SfProRounded" }, ]} > Enter the code sent to your phone </Text> <View style={styles.inputWrapper}> <OtpInput onInputChange={setCode} otpCount={4} animationVariant="fadeSlideDown" focusedBackgroundColor="#000000" enableAutoFocus={true} unfocusedBackgroundColor="#000000" focusedBorderColor="#b4adad" unfocusedBorderColor="#4f4f4f" errorBackgroundColor="#2d1f1f" errorBorderColor="#f44336" enteringAnimated={FadeInUp} inputBorderRadius={10} inputHeight={70} error={error} textStyle={{ fontFamily: fontLoaded ? "HelveticaNowDisplay" : undefined, fontSize: 24, }} onInputFinished={(input: string) => { if (input !== "2342") { setError(true); wait<number>(2000).then<void, never>(() => { setError(false); }); } else { alert("Verification successful!"); } }} inputWidth={70} /> </View> <Text style={[styles.resend, fontLoaded && { fontFamily: "SfProRounded" }]} > Resend code </Text> </View> </GestureHandlerRootView> );}const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#0a0a0a", paddingHorizontal: 32, }, content: { alignItems: "center", gap: 16, top: 100, }, iconBox: { width: 56, height: 56, borderRadius: 16, backgroundColor: "#1a1a1a", justifyContent: "center", alignItems: "center", marginBottom: 8, }, title: { fontSize: 28, fontWeight: "700", color: "#fff", }, subtitle: { fontSize: 15, color: "#555", textAlign: "center", }, inputWrapper: { marginVertical: 24, }, resend: { fontSize: 14, color: "#666", textDecorationLine: "underline", },});Props
React Native Reanimated
