Free referenceMehdi Davoodi
React Native OTP Input
A React Native OTP input is one hidden TextInput that holds the whole code plus a row of View cells that render each digit, which is what keeps paste, backspace and SMS autofill working without a library.
Installation
Only Reanimated, and only for the caret blink and the digit pop. The input
itself is core React Native. Reanimated 4 runs its worklets through
react-native-worklets, so install both.
npx expo install react-native-reanimated react-native-worklets
Reanimated 4 needs the New Architecture, the default since Expo SDK 54. Its
Babel plugin now lives in react-native-worklets/plugin, wired up
for you by babel-preset-expo.
Usage
Paste this into components/OtpInput.tsx and render
<OtpInput onComplete={verify} />. Six digits by default;
pass length for 4 or 8.
// OtpInput.tsx
import { useEffect, useRef, useState } from 'react';
import {
Keyboard,
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withSequence,
withSpring,
withTiming,
} from 'react-native-reanimated';
type OtpInputProps = {
length?: number;
onComplete?: (code: string) => void;
};
export default function OtpInput({ length = 6, onComplete }: OtpInputProps) {
const [value, setValue] = useState('');
const [focused, setFocused] = useState(false);
const inputRef = useRef<TextInput>(null);
const onChangeText = (next: string) => {
// Autofill and paste arrive as one whole string, so sanitize every time.
const digits = next.replace(/[^0-9]/g, '').slice(0, length);
setValue(digits);
if (digits.length === length) {
Keyboard.dismiss();
onComplete?.(digits);
}
};
return (
<Pressable
style={styles.row}
onPress={() => inputRef.current?.focus()}
accessible
accessibilityRole="button"
accessibilityLabel="Verification code"
accessibilityHint={`Enter the ${length} digit code we sent you`}
accessibilityValue={{
text: value.length ? value.split('').join(' ') : 'Empty',
}}
>
{Array.from({ length }, (_, index) => (
<Cell
key={index}
char={value[index] ?? ''}
active={focused && index === Math.min(value.length, length - 1)}
caret={focused && index === value.length}
/>
))}
{/* The real field: laid over the cells, invisible, never tapped directly. */}
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
<TextInput
ref={inputRef}
value={value}
onChangeText={onChangeText}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
maxLength={length}
keyboardType="number-pad"
autoFocus
autoCorrect={false}
caretHidden
textContentType="oneTimeCode"
autoComplete="one-time-code"
importantForAutofill="yes"
style={styles.input}
/>
</View>
</Pressable>
);
}
function Cell({
char,
active,
caret,
}: {
char: string;
active: boolean;
caret: boolean;
}) {
const scale = useSharedValue(1);
useEffect(() => {
if (!char) return;
scale.value = withSequence(
withTiming(1.12, { duration: 90, easing: Easing.out(Easing.quad) }),
withSpring(1, { damping: 12, stiffness: 260 }),
);
}, [char, scale]);
const pop = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
return (
<Animated.View
style={[
styles.cell,
char ? styles.cellFilled : null,
active ? styles.cellActive : null,
pop,
]}
>
{char ? (
<Text style={styles.digit}>{char}</Text>
) : caret ? (
<Caret />
) : null}
</Animated.View>
);
}
function Caret() {
const opacity = useSharedValue(1);
useEffect(() => {
opacity.value = withRepeat(
withSequence(
withTiming(0, { duration: 420, easing: Easing.linear }),
withTiming(1, { duration: 420, easing: Easing.linear }),
),
-1,
false,
);
}, [opacity]);
const blink = useAnimatedStyle(() => ({ opacity: opacity.value }));
return <Animated.View style={[styles.caret, blink]} />;
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', gap: 10 },
cell: {
width: 48,
height: 58,
borderRadius: 12,
borderWidth: 1.5,
borderColor: '#E4E4E7',
backgroundColor: '#FFFFFF',
alignItems: 'center',
justifyContent: 'center',
},
cellFilled: { borderColor: '#18181B', backgroundColor: '#FAFAFA' },
cellActive: { borderColor: '#18181B' },
digit: { fontSize: 24, fontWeight: '600', color: '#18181B' },
caret: { width: 2, height: 26, borderRadius: 1, backgroundColor: '#18181B' },
input: { flex: 1, opacity: 0 },
});
How it works
The TextInput owns the whole code as one string and the cells are
pure rendering: cell n shows value[n]. Because the
platform is editing one ordinary text field, backspace, paste and autofill
already behave, and none of that is code you wrote.
The field is stretched over the row with StyleSheet.absoluteFill
at opacity: 0, inside a View with
pointerEvents="none". That last part is the trick: the input sits
on top for layout and autofill but takes no touches, so the
Pressable underneath catches every tap and calls
inputRef.current?.focus(). Tapping the fourth cell focuses the
field instead of dropping a cursor into the middle of the string.
caretHidden turns off the system cursor because we draw our own:
a 2pt bar in the first empty cell, blinking on a repeating timing, with
withSequence popping each cell as its digit lands. Both run on
the UI thread, so they keep moving while JavaScript verifies.
Gotchas
Autofill does not need a Platform check
The common advice is to branch on Platform.OS and send
sms-otp to Android. Skip it. one-time-code is the
cross platform value: React Native maps it to the native
sms-otp on Android and drops it on iOS, where
textContentType="oneTimeCode" is what fills the QuickType bar.
Do not park the input off screen
The reflex is to hide the field with left: -9999 or zero width.
Do not. An input laid out off screen loses its autofill suggestion, and on
Android it can fail to raise the keyboard at all. Hide it in place, at full
size, with opacity: 0.
The number pad has no way out on iOS
keyboardType="number-pad" has no return key on iOS, so a user who
types four digits and stops is stuck behind the keyboard. The component
dismisses it once the last digit lands, but the incomplete case still needs an
exit: a Cancel action in the header, or a tap outside that calls
Keyboard.dismiss().
A screen reader hears six empty boxes
Left alone, VoiceOver and TalkBack walk past six unlabeled views and never
find the field, because the real one is invisible. Marking the wrapping
Pressable as accessible collapses the row into one
element, and accessibilityRole="button" makes that element
announce as something you activate. Then the label, the hint, and an
accessibilityValue that reads the digits back space separated:
"one two three" is a code, "one hundred twenty three" is a number.
Do I need a library for an OTP input in React Native?
No. A 6 digit verification code input in React Native is one hidden TextInput holding the value, a row of styled Views rendering each character, and a Pressable that focuses the input when a cell is tapped. That is around 160 lines including the animation, and you keep direct control of the keyboard type, the autofill attributes and the styling.
How do I autofill an SMS code in React Native?
Set textContentType="oneTimeCode" for iOS, plus
autoComplete="one-time-code" and
importantForAutofill="yes" for Android, where React Native maps
that value to the native sms-otp. No native module is needed:
both platforms find the code in the arriving message and offer it, iOS in the
QuickType bar and Android through the autofill service. The field must be
focused and on screen, and the code must appear as plain digits in the SMS.
Why one hidden TextInput instead of six separate inputs?
Six inputs means hand writing focus jumping, backspace on an empty box, paste splitting across fields, and autofill that only ever fills the first one. One hidden input has none of those problems, because the OS is editing a single six character string like any other text. The boxes on screen are Views, not fields, so they can look like anything.
How do I clear the code after a failed verification?
Lift the state up: move value and onChangeText into
props so the parent owns the string, then set it to an empty string when the
server rejects the code. The quick alternative is to keep the state internal
and change the component's key prop, which remounts it empty.
Either way, refocus the input right after clearing so the user can retype.
Depends onreact-native-reanimatedreact-native-worklets
Every component on this page is free to copy. Motionary sells the polished, production versions over at the catalog.