How to Build an Animated OTP Input in React Native
Updated
A React Native OTP input should use one real TextInput for typing, paste, and platform autofill, while separate visual slots render the digits and animation. This tutorial builds that pattern with Expo SDK 57, React Native 0.86, and Reanimated 4.5.1, using one shared progress value so updates stay smooth without putting frame-by-frame state in React.
What you'll build
The finished component accepts a numeric code, sanitizes pasted text, focuses from anywhere in the slot row, and calls onComplete only when every position is filled. A single UI-thread progress value drives the row's focus color, lift, and completion pulse; React state changes only when the actual code changes.
This is intentionally a compact teaching implementation. It gives you the architecture you can extend with server validation, error feedback, or the more polished success sequence described at the end.
Prerequisites
Use Expo SDK 57 with React Native 0.86, Reanimated 4.5.1, and react-native-worklets 0.10.1. This example also uses expo-linear-gradient; Gesture Handler, Skia, Blur, and Haptics are not required.
Install SDK-compatible versions through Expo rather than selecting npm versions yourself:
npx expo install react-native-reanimated react-native-worklets expo-linear-gradientReanimated is already configured by the default SDK 57 project template. If you are adding it to an older project, follow the Expo Reanimated documentation instead of copying an outdated Babel configuration.
Use one native input, not six inputs
Six separate inputs look straightforward, but they make paste, deletion, selection, and SMS code autofill much harder. Keep one native TextInput as the source of truth and make the slots presentation-only. The input can be visually transparent without being removed from the accessibility tree.
Create AnimatedOtpInput.tsx:
import { useEffect, useRef, useState } from 'react';
import {
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, {
Easing,
interpolate,
interpolateColor,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withSpring,
withTiming,
} from 'react-native-reanimated';
type AnimatedOtpInputProps = {
length?: number;
onChange?: (code: string) => void;
onComplete?: (code: string) => void;
accessibilityLabel?: string;
};
export function AnimatedOtpInput({
length = 6,
onChange,
onComplete,
accessibilityLabel = 'One-time code',
}: AnimatedOtpInputProps) {
const inputRef = useRef<TextInput>(null);
const [code, setCode] = useState('');
const [focused, setFocused] = useState(false);
const reducedMotion = useReducedMotion();
const progress = useSharedValue(0);
useEffect(() => {
const filled = code.length / length;
if (reducedMotion) {
progress.set(filled);
return;
}
progress.set(
code.length === length
? withSpring(1, { damping: 12, stiffness: 260 })
: withTiming(filled, {
duration: 180,
easing: Easing.out(Easing.cubic),
}),
);
}, [code.length, length, progress, reducedMotion]);
const rowStyle = useAnimatedStyle(() => ({
transform: [
{ translateY: interpolate(progress.get(), [0, 1], [0, -3]) },
{
scale: interpolate(
progress.get(),
[0, 0.9, 1],
[1, 1, reducedMotion ? 1 : 1.025],
),
},
],
}));
const underlineStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
progress.get(),
[0, 1],
['#D7D5E0', '#6757E8'],
),
transform: [{ scaleX: Math.max(0.08, progress.get()) }],
}));
function updateCode(text: string) {
const next = text.replace(/[^0-9]/g, '').slice(0, length);
setCode(next);
onChange?.(next);
if (next.length === length && code.length !== length) {
onComplete?.(next);
}
}
return (
<View style={styles.root}>
<Pressable
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
onPress={() => inputRef.current?.focus()}
>
<Animated.View style={[styles.row, rowStyle]}>
{Array.from({ length }, (_, index) => {
const digit = code[index] ?? '';
const active = focused && index === Math.min(code.length, length - 1);
return (
<LinearGradient
key={index}
colors={['#FFFFFF', '#F7F5FC']}
style={[
styles.slot,
active && styles.activeSlot,
digit !== '' && styles.filledSlot,
]}
>
<Text style={styles.digit}>{digit}</Text>
{active && digit === '' ? <View style={styles.cursor} /> : null}
</LinearGradient>
);
})}
</Animated.View>
</Pressable>
<Animated.View style={[styles.underline, underlineStyle]} />
<TextInput
ref={inputRef}
accessibilityLabel={accessibilityLabel}
accessibilityHint={'Enter the ' + length + '-digit verification code'}
autoComplete="one-time-code"
inputMode="numeric"
keyboardType="number-pad"
maxLength={length}
onBlur={() => setFocused(false)}
onChangeText={updateCode}
onFocus={() => setFocused(true)}
style={styles.input}
textContentType="oneTimeCode"
value={code}
/>
</View>
);
}
const styles = StyleSheet.create({
root: { alignSelf: 'center', paddingVertical: 12 },
row: { flexDirection: 'row', gap: 8 },
slot: {
alignItems: 'center',
borderColor: '#D8D7DF',
borderRadius: 11,
borderWidth: 1,
height: 55,
justifyContent: 'center',
width: 48,
},
activeSlot: { borderColor: '#6757E8', borderWidth: 2 },
filledSlot: { backgroundColor: '#FBFAFF' },
digit: { color: '#17151F', fontSize: 22, fontVariant: ['tabular-nums'] },
cursor: { backgroundColor: '#6757E8', height: 24, width: 2 },
underline: {
alignSelf: 'center',
borderRadius: 2,
height: 3,
marginTop: 10,
width: '100%',
},
input: {
height: 1,
opacity: 0.01,
position: 'absolute',
width: 1,
},
});Why the shared progress value matters
progress is the normalized completion state: zero means empty and one means complete. Both the row transform and underline color derive from it, so there is no chance for separate animations to drift apart. Reanimated evaluates those styles on the UI thread; React only rerenders when a digit is entered, pasted, or deleted.
Using progress.set() and progress.get() also matches Reanimated 4's React Compiler-friendly API. The spring is reserved for completion, where a small overshoot communicates success, while ordinary input changes use a short cubic timing curve.
Do not animate the hidden input itself. It owns text semantics, selection, and autofill; the visible layer owns appearance. That division keeps the component predictable on iOS, Android, and web.
Handle paste and autofill correctly
onChangeText may receive one character, a full pasted code, or an autofilled value. Sanitizing the entire string with replace and then slicing once handles all three paths. Avoid advancing focus manually because there is only one focus target.
For iOS, textContentType="oneTimeCode" advertises the field to the system. autoComplete="one-time-code" provides the cross-platform hint supported by React Native. Test with a real device and your actual authentication message; simulators and development autofill do not reproduce every keyboard behavior.
Keep verification outside the component. onComplete tells the screen that the expected number of digits exists, but only the server can decide whether the code is valid. If verification fails, preserve the value long enough for the user to understand the error, announce the message, and then let them edit or clear it.
Treat resend as a separate state machine. Disable the resend action only for the real cooldown period, show the remaining wait as text, and replace the server challenge when a new code is issued. A resent code may invalidate the previous one, so clear stale errors and decide explicitly whether the field should clear. Also prevent an older verification response from overwriting the result of a newer code: cancel the request when possible, or associate each response with the submitted string before updating the screen.
Accessibility
Expose one input to assistive technology, not six decorative boxes. The slot row is hidden with accessibilityElementsHidden and importantForAccessibility, while the actual TextInput retains a concise accessibilityLabel and a length-specific hint. This prevents a screen reader from announcing every digit twice.
useReducedMotion returns the system preference synchronously. The example changes progress immediately when motion is reduced, preserving focus, completion, and color states without lift or spring motion. Never use reduced motion as a reason to hide validation feedback.
If you add an error message, render visible text with accessibilityRole="alert" and use language such as “Code expired. Request a new code.” Color alone is not enough. Maintain a minimum 44-point touch target around the slot row even though the visible boxes may be smaller.
Performance
One native input and one shared value are inexpensive. The animation styles read progress on the UI thread, so each frame does not cross to JavaScript and does not call setState. React only reconciles the six lightweight slots when the string changes.
Keep the slot count small and stable. If you need variable-length codes, reset the value when length changes rather than leaving unreachable digits in state. Memoization is usually unnecessary for four to eight slots; profile before introducing component complexity.
Avoid network work directly inside onChangeText. Trigger verification from onComplete, cancel obsolete requests when the code changes, and disable duplicate submissions at the screen level. Animation should remain independent from network latency.
Test the component with pasted spaces, hyphens, leading zeros, hardware keyboards, autofill, and backspace from a complete value. Those cases exercise the single-input architecture more effectively than tapping one digit at a time in a simulator. Keep analytics free of the actual code; an OTP is authentication data, not harmless form text.
Common pitfalls
- Using one input per digit. Paste, backspace, focus order, and autofill become a distributed state machine. One input is simpler and more accessible.
- Parsing the code as a number. Numeric conversion removes leading zeros. Store OTP values as strings from input through verification.
- Calling completion repeatedly. Compare against the previous length, as the example does, so parent rerenders do not resubmit a complete code.
- Making the real input inaccessible.
display: 'none'or removing it from the tree can break the keyboard and autofill. Keep it mounted and labeled. - Animating with React state on every frame. Use a shared value for visual progress; reserve state for the actual code and focus semantics.
- Assuming keyboard hints are validation.
keyboardTypeandinputModeinfluence the keyboard, but pasted text must still be sanitized.
For the underlying primitives, see the official Reanimated shared values guide and Expo LinearGradient reference.
Skip the build: production-ready version
The production-ready OTP Code Input adds controlled and uncontrolled APIs, imperative focus/clear/shake/succeed controls, dark mode, configurable sizing and colors, paste-friendly validation feedback, and a staggered success sweep. It is packaged with documented props and accessibility behavior and is tested across iOS, Android, and web. You can also review the Native Animation CLI workflow for adding catalog pieces to a project.
FAQ
How do I support SMS autofill in a React Native OTP input?
Use one native TextInput with textContentType="oneTimeCode" and autoComplete="one-time-code". The operating system still decides whether to offer the code, so verify the behavior on real devices and with production-like SMS formatting.
Should each OTP digit be a separate TextInput?
Usually no. A single input with visual slots handles paste, deletion, focus, and accessibility more reliably, while still giving you complete control over each slot's appearance.
How do I paste a complete verification code?
Sanitize the whole value received by onChangeText and slice it to the configured length. Do not assume the callback contains only one newly typed character.
Can this pattern handle four-digit and six-digit codes?
Yes. Make the slot count and maxLength use the same length prop, and keep the code as a string so leading zeros remain intact.