Skip to content

OTP Input

The OtpInput component is a specialized input field designed for capturing single digits in OTP (One-Time Password) verification flows. It automatically validates numeric input, handles backspace navigation, and provides a consistent visual design for verification codes.

import { OtpInput } from '@space-uy/pulsar-ui';
<OtpInput placeholder="*" onChangeText={(digit) => console.log(digit)} />
PropertyTypeRequiredDefault valueDescription
placeholderstring'*'Placeholder character when input is empty
onChangeText(text: string) => void-Callback when input text changes
onKeyPress(event: NativeSyntheticEvent<TextInputKeyPressEventData>) => void-Callback for key press events
editablebooleantrueWhether the input is editable
styleStyleProp<ViewStyle>-Custom styles for the input
...restTextInputProps-Additional TextInput props
const [digit, setDigit] = useState('');
<OtpInput placeholder="0" onChangeText={setDigit} value={digit} />;
const [otpCode, setOtpCode] = useState(['', '', '', '', '', '']);
const inputRefs = useRef<Array<TextInput | null>>([]);
const handleOtpChange = (value: string, index: number) => {
const newOtpCode = [...otpCode];
newOtpCode[index] = value;
setOtpCode(newOtpCode);
// Auto-focus next input
if (value && index < 5) {
inputRefs.current[index + 1]?.focus();
}
};
const handleKeyPress = (event: any, index: number) => {
if (event.nativeEvent.key === 'Backspace' && !otpCode[index] && index > 0) {
inputRefs.current[index - 1]?.focus();
}
};
<View style={{ flexDirection: 'row', gap: 12, justifyContent: 'center' }}>
{otpCode.map((digit, index) => (
<OtpInput
key={index}
ref={(ref) => (inputRefs.current[index] = ref)}
placeholder="0"
value={digit}
onChangeText={(value) => handleOtpChange(value, index)}
onKeyPress={(event) => handleKeyPress(event, index)}
/>
))}
</View>;
const [otpCode, setOtpCode] = useState(['', '', '', '', '', '']);
const [isValid, setIsValid] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const inputRefs = useRef<Array<TextInput | null>>([]);
const handleOtpChange = (value: string, index: number) => {
const newOtpCode = [...otpCode];
newOtpCode[index] = value;
setOtpCode(newOtpCode);
setIsValid(true); // Reset validation state
// Auto-focus next input
if (value && index < 5) {
inputRefs.current[index + 1]?.focus();
}
// Auto-submit when complete
if (index === 5 && value) {
handleVerification(newOtpCode);
}
};
const handleVerification = async (code: string[]) => {
const codeString = code.join('');
if (codeString.length === 6) {
setIsLoading(true);
try {
await verifyOtpCode(codeString);
// Handle success
} catch (error) {
setIsValid(false);
setOtpCode(['', '', '', '', '', '']);
inputRefs.current[0]?.focus();
} finally {
setIsLoading(false);
}
}
};
<View style={{ alignItems: 'center', gap: 20 }}>
<Text variant="h4">Enter verification code</Text>
<Text variant="pm" style={{ textAlign: 'center', opacity: 0.7 }}>
We sent a 6-digit code to {maskedPhone}
</Text>
<View style={{ flexDirection: 'row', gap: 12 }}>
{otpCode.map((digit, index) => (
<OtpInput
key={index}
ref={(ref) => (inputRefs.current[index] = ref)}
placeholder="0"
value={digit}
onChangeText={(value) => handleOtpChange(value, index)}
onKeyPress={(event) => handleKeyPress(event, index)}
editable={!isLoading}
style={{
borderColor: !isValid ? colors.destructive : colors.border,
backgroundColor: isLoading ? colors.altBackground : colors.background,
}}
/>
))}
</View>
{!isValid && (
<Text variant="caption" style={{ color: colors.destructive }}>
Invalid code. Please try again.
</Text>
)}
{isLoading && (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<LoadingIndicator size={16} />
<Text variant="pm">Verifying...</Text>
</View>
)}
</View>;
const [otpCode, setOtpCode] = useState(['', '', '', '', '', '']);
const [countdown, setCountdown] = useState(60);
const [canResend, setCanResend] = useState(false);
useEffect(() => {
let timer: NodeJS.Timeout;
if (countdown > 0) {
timer = setTimeout(() => setCountdown(countdown - 1), 1000);
} else {
setCanResend(true);
}
return () => clearTimeout(timer);
}, [countdown]);
const handleResendCode = async () => {
if (!canResend) return;
try {
await resendOtpCode();
setCountdown(60);
setCanResend(false);
setOtpCode(['', '', '', '', '', '']);
inputRefs.current[0]?.focus();
} catch (error) {
// Handle error
}
};
<View style={{ alignItems: 'center', gap: 20 }}>
<View style={{ flexDirection: 'row', gap: 12 }}>
{otpCode.map((digit, index) => (
<OtpInput
key={index}
ref={(ref) => (inputRefs.current[index] = ref)}
placeholder="0"
value={digit}
onChangeText={(value) => handleOtpChange(value, index)}
/>
))}
</View>
<View style={{ alignItems: 'center', gap: 8 }}>
<Text variant="pm">Didn't receive the code?</Text>
<Button
text={canResend ? 'Resend code' : `Resend in ${countdown}s`}
variant="transparent"
onPress={handleResendCode}
disabled={!canResend}
/>
</View>
</View>;
const [otpCode, setOtpCode] = useState(['', '', '', '']);
<View style={{ alignItems: 'center', gap: 16 }}>
<Text variant="h4">Security Code</Text>
<View style={{ flexDirection: 'row', gap: 16 }}>
{otpCode.map((digit, index) => (
<OtpInput
key={index}
placeholder=""
value={digit}
onChangeText={(value) => handleOtpChange(value, index)}
style={{
width: 60,
height: 60,
borderRadius: 12,
borderWidth: 2,
borderColor: digit ? colors.primary : colors.border,
backgroundColor: digit ? colors.primary + '10' : colors.background,
fontSize: 24,
fontWeight: 'bold',
}}
/>
))}
</View>
<Text variant="caption" style={{ textAlign: 'center', opacity: 0.7 }}>
Enter the 4-digit code from your authenticator app
</Text>
</View>;
const [authMethod, setAuthMethod] = useState<'sms' | 'app'>('sms');
const [otpCode, setOtpCode] = useState(['', '', '', '', '', '']);
<Card>
<Text variant="h4">Two-Factor Authentication</Text>
<View style={{ marginTop: 16, gap: 16 }}>
<View style={{ flexDirection: 'row', gap: 12 }}>
<Chip
text="SMS Code"
onPress={() => setAuthMethod('sms')}
style={{
backgroundColor:
authMethod === 'sms' ? colors.primary : colors.altBackground,
}}
/>
<Chip
text="Authenticator App"
onPress={() => setAuthMethod('app')}
style={{
backgroundColor:
authMethod === 'app' ? colors.primary : colors.altBackground,
}}
/>
</View>
<Text variant="pm" style={{ opacity: 0.7 }}>
{authMethod === 'sms'
? 'Enter the 6-digit code sent to your phone'
: 'Enter the 6-digit code from your authenticator app'}
</Text>
<View style={{ flexDirection: 'row', gap: 8, justifyContent: 'center' }}>
{otpCode.map((digit, index) => (
<OtpInput
key={index}
placeholder="0"
value={digit}
onChangeText={(value) => handleOtpChange(value, index)}
/>
))}
</View>
</View>
</Card>;
  • Input automatically focuses next field when a digit is entered
  • Backspace navigation moves to previous field when current is empty
  • Only numeric input is accepted (0-9)
  • Paste operations are handled gracefully to fill multiple fields
  • The component maintains consistent styling with the design system
  • Numeric Only: Automatically filters non-numeric input
  • Single Character: Prevents entering more than one character
  • Auto-focus: Seamless navigation between input fields
  • Paste Support: Intelligent handling of pasted OTP codes
  • Error States: Visual feedback for invalid codes
  • Each input is properly labeled for screen readers
  • Keyboard navigation works seamlessly between fields
  • Focus management ensures smooth user experience
  • High contrast support for better visibility
  • Proper text input type for mobile keyboards