@leo-showdar/react-native-otp-input-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 leo-showdar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,221 @@
1
+ # @leo-showdar/react-native-otp-input-kit
2
+
3
+ Lightweight React Native TypeScript components for OTP input and resend timer flows.
4
+
5
+ The package is designed for login, phone verification, email verification, and two-step verification screens. It ships only React Native components and hooks, with no native modules and no runtime dependencies beyond React and React Native.
6
+
7
+ ## Installation
8
+
9
+ ```sh
10
+ npm install @leo-showdar/react-native-otp-input-kit
11
+ ```
12
+
13
+ ## Basic Usage
14
+
15
+ ```tsx
16
+ import * as React from 'react';
17
+ import { Alert, SafeAreaView, StyleSheet, Text } from 'react-native';
18
+ import { OtpInput, OtpResendTimer } from '@leo-showdar/react-native-otp-input-kit';
19
+
20
+ export function VerifyPhoneScreen() {
21
+ return (
22
+ <SafeAreaView style={styles.screen}>
23
+ <Text style={styles.title}>Enter the code sent to your phone</Text>
24
+
25
+ <OtpInput
26
+ autoFocus
27
+ length={6}
28
+ onComplete={(code) => Alert.alert('Code complete', code)}
29
+ />
30
+
31
+ <OtpResendTimer onResend={() => Alert.alert('New code sent')} />
32
+ </SafeAreaView>
33
+ );
34
+ }
35
+
36
+ const styles = StyleSheet.create({
37
+ screen: {
38
+ flex: 1,
39
+ justifyContent: 'center',
40
+ padding: 24,
41
+ rowGap: 20,
42
+ },
43
+ title: {
44
+ color: '#111827',
45
+ fontSize: 20,
46
+ fontWeight: '700',
47
+ textAlign: 'center',
48
+ },
49
+ });
50
+ ```
51
+
52
+ ## Controlled Usage
53
+
54
+ ```tsx
55
+ import * as React from 'react';
56
+ import { Button, View } from 'react-native';
57
+ import { OtpInput } from '@leo-showdar/react-native-otp-input-kit';
58
+
59
+ export function ControlledOtpExample() {
60
+ const [code, setCode] = React.useState('');
61
+
62
+ return (
63
+ <View>
64
+ <OtpInput
65
+ value={code}
66
+ onChangeCode={setCode}
67
+ allowCellSelection={false}
68
+ onComplete={(completedCode) => {
69
+ // Submit completedCode to your verification endpoint.
70
+ }}
71
+ />
72
+
73
+ <Button title="Clear" onPress={() => setCode('')} />
74
+ </View>
75
+ );
76
+ }
77
+ ```
78
+
79
+ ## Custom Styles
80
+
81
+ ```tsx
82
+ import { OtpInput } from '@leo-showdar/react-native-otp-input-kit';
83
+
84
+ export function StyledOtpExample() {
85
+ return (
86
+ <OtpInput
87
+ containerStyle={{ justifyContent: 'center' }}
88
+ cellStyle={{
89
+ backgroundColor: '#F9FAFB',
90
+ borderColor: '#CBD5E1',
91
+ borderRadius: 12,
92
+ height: 56,
93
+ width: 52,
94
+ }}
95
+ focusedCellStyle={{
96
+ borderColor: '#0F766E',
97
+ borderWidth: 2,
98
+ }}
99
+ filledCellStyle={{
100
+ borderColor: '#64748B',
101
+ }}
102
+ textStyle={{
103
+ color: '#0F172A',
104
+ fontSize: 24,
105
+ }}
106
+ />
107
+ );
108
+ }
109
+ ```
110
+
111
+ ## Error State
112
+
113
+ ```tsx
114
+ import * as React from 'react';
115
+ import { Text, View } from 'react-native';
116
+ import { OtpInput } from '@leo-showdar/react-native-otp-input-kit';
117
+
118
+ export function OtpErrorExample() {
119
+ const [code, setCode] = React.useState('');
120
+ const [hasError, setHasError] = React.useState(false);
121
+
122
+ return (
123
+ <View>
124
+ <OtpInput
125
+ value={code}
126
+ onChangeCode={(nextCode) => {
127
+ setCode(nextCode);
128
+ setHasError(false);
129
+ }}
130
+ onComplete={() => setHasError(true)}
131
+ error={hasError}
132
+ />
133
+
134
+ {hasError ? (
135
+ <Text style={{ color: '#B91C1C', marginTop: 8 }}>
136
+ That code did not match. Check the message and try again.
137
+ </Text>
138
+ ) : null}
139
+ </View>
140
+ );
141
+ }
142
+ ```
143
+
144
+ ## Secure OTP
145
+
146
+ ```tsx
147
+ import { OtpInput } from '@leo-showdar/react-native-otp-input-kit';
148
+
149
+ export function SecureOtpExample() {
150
+ return <OtpInput secureTextEntry length={6} />;
151
+ }
152
+ ```
153
+
154
+ ## Resend Timer
155
+
156
+ ```tsx
157
+ import * as React from 'react';
158
+ import { Text, View } from 'react-native';
159
+ import { OtpResendTimer } from '@leo-showdar/react-native-otp-input-kit';
160
+
161
+ export function ResendTimerExample() {
162
+ const [message, setMessage] = React.useState('');
163
+
164
+ return (
165
+ <View>
166
+ <OtpResendTimer
167
+ seconds={45}
168
+ countdownLabel="Request a new code in"
169
+ resendLabel="Send a new code"
170
+ onResend={() => setMessage('A new verification code was sent.')}
171
+ />
172
+
173
+ {message ? <Text>{message}</Text> : null}
174
+ </View>
175
+ );
176
+ }
177
+ ```
178
+
179
+ ## OtpInput Props
180
+
181
+ | Prop | Type | Default | Description |
182
+ | --- | --- | --- | --- |
183
+ | `length` | `number` | `6` | Number of OTP digits. |
184
+ | `value` | `string` | `undefined` | Controlled OTP value. |
185
+ | `defaultValue` | `string` | `''` | Initial value for uncontrolled usage. |
186
+ | `onChangeCode` | `(code: string) => void` | `undefined` | Called when the OTP changes. |
187
+ | `onComplete` | `(code: string) => void` | `undefined` | Called when the OTP reaches `length`. |
188
+ | `allowCellSelection` | `boolean` | `true` | Allows tapping any cell to edit it. Set `false` for sequential input and right-to-left delete behavior. |
189
+ | `autoFocus` | `boolean` | `false` | Focuses the first cell on mount. |
190
+ | `disabled` | `boolean` | `false` | Disables editing and lowers opacity. |
191
+ | `error` | `boolean` | `false` | Applies error styling and accessibility state. |
192
+ | `secureTextEntry` | `boolean` | `false` | Masks entered digits. |
193
+ | `placeholder` | `string` | `undefined` | Placeholder shown in empty cells. |
194
+ | `keyboardType` | `TextInputProps['keyboardType']` | `'number-pad'` | Keyboard type for each cell. |
195
+ | `containerStyle` | `ViewStyle` | `undefined` | Style override for the cell row. |
196
+ | `cellStyle` | `ViewStyle` | `undefined` | Base style override for each cell. |
197
+ | `focusedCellStyle` | `ViewStyle` | `undefined` | Style applied to the focused cell. |
198
+ | `filledCellStyle` | `ViewStyle` | `undefined` | Style applied to cells with a value. |
199
+ | `errorCellStyle` | `ViewStyle` | `undefined` | Style applied when `error` is true. |
200
+ | `textStyle` | `TextStyle` | `undefined` | Text style override for each cell. |
201
+ | `testID` | `string` | `undefined` | Test id for the container and cell ids. |
202
+
203
+ ## OtpResendTimer Props
204
+
205
+ | Prop | Type | Default | Description |
206
+ | --- | --- | --- | --- |
207
+ | `seconds` | `number` | `60` | Countdown duration in seconds. |
208
+ | `autoStart` | `boolean` | `true` | Starts the countdown on mount. |
209
+ | `onResend` | `() => void` | `undefined` | Called when resend is pressed. |
210
+ | `onFinish` | `() => void` | `undefined` | Called when the countdown reaches zero. |
211
+ | `renderText` | `(remainingSeconds: number, canResend: boolean) => React.ReactNode` | `undefined` | Custom text renderer. |
212
+ | `resendLabel` | `string` | `'Resend code'` | Label shown when resend is available. |
213
+ | `countdownLabel` | `string` | `'Resend code in'` | Label shown while counting down. |
214
+ | `disabled` | `boolean` | `false` | Prevents resend presses. |
215
+ | `textStyle` | `TextStyle` | `undefined` | Style for default countdown text. |
216
+ | `resendTextStyle` | `TextStyle` | `undefined` | Style for default resend text. |
217
+ | `testID` | `string` | `undefined` | Test id for the pressable timer. |
218
+
219
+ ## License
220
+
221
+ MIT
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@leo-showdar/react-native-otp-input-kit",
3
+ "version": "0.1.0",
4
+ "description": "Lightweight React Native TypeScript components for OTP input and resend timer flows.",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "react-native": "src/index.ts",
8
+ "source": "src/index.ts",
9
+ "files": [
10
+ "src",
11
+ "README.md"
12
+ ],
13
+ "scripts": {
14
+ "test": "jest",
15
+ "typecheck": "tsc --noEmit"
16
+ },
17
+ "keywords": [
18
+ "react-native",
19
+ "otp",
20
+ "otp-input",
21
+ "verification-code",
22
+ "resend-timer",
23
+ "typescript"
24
+ ],
25
+ "author": "leo-showdar",
26
+ "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/leo-showdar/react-native-otp-input-kit.git"
30
+ },
31
+ "bugs": {
32
+ "url": "https://github.com/leo-showdar/react-native-otp-input-kit/issues"
33
+ },
34
+ "homepage": "https://github.com/leo-showdar/react-native-otp-input-kit#readme",
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "peerDependencies": {
39
+ "react": ">=18.2.0",
40
+ "react-native": ">=0.72.0"
41
+ },
42
+ "devDependencies": {
43
+ "@testing-library/react-native": "^12.7.2",
44
+ "@types/jest": "^29.5.12",
45
+ "@types/react": "^18.3.31",
46
+ "babel-jest": "^29.7.0",
47
+ "jest": "^29.7.0",
48
+ "react": "^18.2.0",
49
+ "react-native": "^0.75.0",
50
+ "react-test-renderer": "^18.2.0",
51
+ "typescript": "^5.5.4"
52
+ }
53
+ }
@@ -0,0 +1,96 @@
1
+ import * as React from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import type { OtpInputProps } from '../../types';
5
+ import { useOtpInput } from '../../hooks/useOtpInput';
6
+ import { OtpInputCell } from './OtpInputCell';
7
+ import { otpInputStyles } from './styles';
8
+
9
+ export function OtpInput({
10
+ length = 6,
11
+ value,
12
+ defaultValue,
13
+ onChangeCode,
14
+ onComplete,
15
+ allowCellSelection = true,
16
+ autoFocus = false,
17
+ disabled = false,
18
+ error = false,
19
+ secureTextEntry = false,
20
+ placeholder,
21
+ keyboardType = 'number-pad',
22
+ containerStyle,
23
+ cellStyle,
24
+ focusedCellStyle,
25
+ filledCellStyle,
26
+ errorCellStyle,
27
+ textStyle,
28
+ testID,
29
+ }: OtpInputProps) {
30
+ const {
31
+ cellValues,
32
+ focusedIndex,
33
+ inputRefs,
34
+ setFocusedIndex,
35
+ focusCell,
36
+ handleChangeText,
37
+ handleKeyPress,
38
+ } = useOtpInput({
39
+ length,
40
+ value,
41
+ defaultValue,
42
+ onChangeCode,
43
+ onComplete,
44
+ allowCellSelection,
45
+ });
46
+ const didAutoFocusRef = React.useRef(false);
47
+
48
+ React.useEffect(() => {
49
+ if (autoFocus && !disabled && !didAutoFocusRef.current) {
50
+ didAutoFocusRef.current = true;
51
+ requestAnimationFrame(() => focusCell(0));
52
+ }
53
+ }, [autoFocus, disabled, focusCell]);
54
+
55
+ return (
56
+ <View
57
+ accessibilityLabel="Verification code input"
58
+ accessibilityHint={error ? 'The entered verification code has an error.' : undefined}
59
+ accessibilityState={{ disabled }}
60
+ style={[
61
+ otpInputStyles.container,
62
+ disabled && otpInputStyles.disabledContainer,
63
+ containerStyle,
64
+ ]}
65
+ testID={testID}
66
+ >
67
+ {cellValues.map((cellValue, index) => (
68
+ <OtpInputCell
69
+ key={index}
70
+ value={cellValue}
71
+ index={index}
72
+ length={length}
73
+ focused={focusedIndex === index}
74
+ filled={cellValue.length > 0}
75
+ error={error}
76
+ disabled={disabled}
77
+ secureTextEntry={secureTextEntry}
78
+ placeholder={placeholder}
79
+ keyboardType={keyboardType}
80
+ cellStyle={cellStyle}
81
+ focusedCellStyle={focusedCellStyle}
82
+ filledCellStyle={filledCellStyle}
83
+ errorCellStyle={errorCellStyle}
84
+ textStyle={textStyle}
85
+ testID={testID}
86
+ inputRef={(input) => {
87
+ inputRefs.current[index] = input;
88
+ }}
89
+ onFocus={setFocusedIndex}
90
+ onChangeText={handleChangeText}
91
+ onKeyPress={handleKeyPress}
92
+ />
93
+ ))}
94
+ </View>
95
+ );
96
+ }
@@ -0,0 +1,98 @@
1
+ import * as React from 'react';
2
+ import { Pressable, Text, TextInput } from 'react-native';
3
+
4
+ import type { OtpInputCellProps } from '../../types';
5
+ import { otpInputStyles } from './styles';
6
+
7
+ export const OtpInputCell = React.memo(function OtpInputCell({
8
+ value,
9
+ index,
10
+ length = 6,
11
+ focused = false,
12
+ filled = false,
13
+ error = false,
14
+ disabled = false,
15
+ secureTextEntry = false,
16
+ placeholder,
17
+ keyboardType = 'number-pad',
18
+ cellStyle,
19
+ focusedCellStyle,
20
+ filledCellStyle,
21
+ errorCellStyle,
22
+ textStyle,
23
+ testID,
24
+ inputRef,
25
+ onFocus,
26
+ onChangeText,
27
+ onKeyPress,
28
+ }: OtpInputCellProps) {
29
+ const localInputRef = React.useRef<TextInput | null>(null);
30
+ const displayValue = secureTextEntry && value ? '•' : value || placeholder || '';
31
+ const showPlaceholder = !value && Boolean(placeholder);
32
+ const setInputRef = React.useCallback(
33
+ (input: TextInput | null) => {
34
+ localInputRef.current = input;
35
+ inputRef?.(input);
36
+ },
37
+ [inputRef]
38
+ );
39
+
40
+ const focusInput = React.useCallback(() => {
41
+ if (disabled) {
42
+ return;
43
+ }
44
+
45
+ onFocus?.(index);
46
+ requestAnimationFrame(() => {
47
+ localInputRef.current?.focus();
48
+ });
49
+ }, [disabled, index, onFocus]);
50
+
51
+ return (
52
+ <Pressable
53
+ disabled={disabled}
54
+ onPress={focusInput}
55
+ style={[
56
+ otpInputStyles.cell,
57
+ filled && otpInputStyles.filledCell,
58
+ focused && otpInputStyles.focusedCell,
59
+ error && otpInputStyles.errorCell,
60
+ cellStyle,
61
+ filled && filledCellStyle,
62
+ focused && focusedCellStyle,
63
+ error && errorCellStyle,
64
+ ]}
65
+ >
66
+ <Text
67
+ style={[
68
+ otpInputStyles.cellText,
69
+ showPlaceholder && otpInputStyles.placeholderText,
70
+ textStyle,
71
+ ]}
72
+ >
73
+ {displayValue}
74
+ </Text>
75
+
76
+ <TextInput
77
+ ref={setInputRef}
78
+ value=""
79
+ onFocus={() => onFocus?.(index)}
80
+ onChangeText={(text) => onChangeText(text, index)}
81
+ onKeyPress={(event) => onKeyPress(event, index)}
82
+ editable={!disabled}
83
+ caretHidden
84
+ contextMenuHidden
85
+ selectTextOnFocus={false}
86
+ selection={{ start: 0, end: 0 }}
87
+ keyboardType={keyboardType}
88
+ textContentType="oneTimeCode"
89
+ autoComplete="sms-otp"
90
+ maxLength={length}
91
+ accessibilityLabel={`Verification code digit ${index + 1}`}
92
+ accessibilityState={{ disabled }}
93
+ testID={testID ? `${testID}-cell-${index}` : undefined}
94
+ style={otpInputStyles.hiddenInput}
95
+ />
96
+ </Pressable>
97
+ );
98
+ });
@@ -0,0 +1,2 @@
1
+ export { OtpInput } from './OtpInput';
2
+ export { OtpInputCell } from './OtpInputCell';
@@ -0,0 +1,56 @@
1
+ import { StyleSheet } from 'react-native';
2
+
3
+ export const otpInputStyles = StyleSheet.create({
4
+ container: {
5
+ alignItems: 'center',
6
+ flexDirection: 'row',
7
+ gap: 10,
8
+ },
9
+ disabledContainer: {
10
+ opacity: 0.48,
11
+ },
12
+ cell: {
13
+ alignItems: 'center',
14
+ backgroundColor: '#FFFFFF',
15
+ borderColor: '#D1D5DB',
16
+ borderRadius: 10,
17
+ borderWidth: 1,
18
+ height: 52,
19
+ justifyContent: 'center',
20
+ minWidth: 48,
21
+ overflow: 'hidden',
22
+ width: 48,
23
+ },
24
+ hiddenInput: {
25
+ color: 'transparent',
26
+ fontSize: 1,
27
+ height: 1,
28
+ left: '50%',
29
+ opacity: 0,
30
+ padding: 0,
31
+ position: 'absolute',
32
+ textAlign: 'center',
33
+ top: '50%',
34
+ width: 1,
35
+ },
36
+ cellText: {
37
+ color: '#111827',
38
+ fontSize: 22,
39
+ fontWeight: '600',
40
+ textAlign: 'center',
41
+ },
42
+ placeholderText: {
43
+ color: '#9CA3AF',
44
+ },
45
+ focusedCell: {
46
+ borderColor: '#2563EB',
47
+ borderWidth: 2,
48
+ },
49
+ filledCell: {
50
+ borderColor: '#9CA3AF',
51
+ },
52
+ errorCell: {
53
+ backgroundColor: '#FEF2F2',
54
+ borderColor: '#DC2626',
55
+ },
56
+ });
@@ -0,0 +1,86 @@
1
+ import * as React from 'react';
2
+ import { Pressable, StyleSheet, Text } from 'react-native';
3
+
4
+ import type { OtpResendTimerProps } from '../../types';
5
+ import { useCountdown } from '../../hooks/useCountdown';
6
+
7
+ export function OtpResendTimer({
8
+ seconds = 60,
9
+ autoStart = true,
10
+ onResend,
11
+ onFinish,
12
+ renderText,
13
+ resendLabel = 'Resend code',
14
+ countdownLabel = 'Resend code in',
15
+ disabled = false,
16
+ textStyle,
17
+ resendTextStyle,
18
+ testID,
19
+ }: OtpResendTimerProps) {
20
+ const { remainingSeconds, canResend, reset, start } = useCountdown({
21
+ seconds,
22
+ autoStart,
23
+ onFinish,
24
+ });
25
+
26
+ const resendEnabled = canResend && !disabled;
27
+
28
+ const handlePress = React.useCallback(() => {
29
+ if (!resendEnabled) {
30
+ return;
31
+ }
32
+
33
+ onResend?.();
34
+ reset();
35
+ start();
36
+ }, [onResend, resendEnabled, reset, start]);
37
+
38
+ const content = renderText ? (
39
+ renderText(remainingSeconds, resendEnabled)
40
+ ) : (
41
+ <Text
42
+ style={[
43
+ styles.text,
44
+ resendEnabled && styles.resendText,
45
+ textStyle,
46
+ resendEnabled && resendTextStyle,
47
+ ]}
48
+ >
49
+ {resendEnabled ? resendLabel : `${countdownLabel} ${remainingSeconds}s`}
50
+ </Text>
51
+ );
52
+
53
+ return (
54
+ <Pressable
55
+ accessibilityRole="button"
56
+ accessibilityLabel={resendEnabled ? resendLabel : `${countdownLabel} ${remainingSeconds} seconds`}
57
+ accessibilityState={{ disabled: !resendEnabled }}
58
+ disabled={!resendEnabled}
59
+ onPress={handlePress}
60
+ hitSlop={8}
61
+ style={styles.pressable}
62
+ testID={testID}
63
+ >
64
+ {content}
65
+ </Pressable>
66
+ );
67
+ }
68
+
69
+ const styles = StyleSheet.create({
70
+ pressable: {
71
+ alignItems: 'center',
72
+ justifyContent: 'center',
73
+ minHeight: 44,
74
+ minWidth: 44,
75
+ },
76
+ text: {
77
+ color: '#4B5563',
78
+ fontSize: 15,
79
+ fontWeight: '500',
80
+ textAlign: 'center',
81
+ },
82
+ resendText: {
83
+ color: '#2563EB',
84
+ fontWeight: '600',
85
+ },
86
+ });
@@ -0,0 +1 @@
1
+ export { OtpResendTimer } from './OtpResendTimer';
@@ -0,0 +1,97 @@
1
+ import * as React from 'react';
2
+
3
+ import type { UseCountdownOptions, UseCountdownReturn } from '../types';
4
+
5
+ export function useCountdown({
6
+ seconds = 60,
7
+ autoStart = true,
8
+ onFinish,
9
+ }: UseCountdownOptions = {}): UseCountdownReturn {
10
+ const safeSeconds = Math.max(0, seconds);
11
+ const onFinishRef = React.useRef(onFinish);
12
+ const intervalRef = React.useRef<ReturnType<typeof setInterval> | null>(null);
13
+ const [remainingSeconds, setRemainingSeconds] = React.useState(safeSeconds);
14
+ const [running, setRunning] = React.useState(autoStart && safeSeconds > 0);
15
+
16
+ React.useEffect(() => {
17
+ onFinishRef.current = onFinish;
18
+ }, [onFinish]);
19
+
20
+ const stop = React.useCallback(() => {
21
+ setRunning(false);
22
+
23
+ if (intervalRef.current) {
24
+ clearInterval(intervalRef.current);
25
+ intervalRef.current = null;
26
+ }
27
+ }, []);
28
+
29
+ const start = React.useCallback(() => {
30
+ if (safeSeconds <= 0) {
31
+ setRemainingSeconds(0);
32
+ setRunning(false);
33
+ return;
34
+ }
35
+
36
+ setRemainingSeconds((current) => (current > 0 ? current : safeSeconds));
37
+ setRunning(true);
38
+ }, [safeSeconds]);
39
+
40
+ const reset = React.useCallback(() => {
41
+ setRemainingSeconds(safeSeconds);
42
+ setRunning(autoStart && safeSeconds > 0);
43
+ }, [autoStart, safeSeconds]);
44
+
45
+ React.useEffect(() => {
46
+ setRemainingSeconds(safeSeconds);
47
+ setRunning(autoStart && safeSeconds > 0);
48
+ }, [autoStart, safeSeconds]);
49
+
50
+ React.useEffect(() => {
51
+ if (!running) {
52
+ return undefined;
53
+ }
54
+
55
+ intervalRef.current = setInterval(() => {
56
+ setRemainingSeconds((current) => {
57
+ if (current <= 1) {
58
+ if (intervalRef.current) {
59
+ clearInterval(intervalRef.current);
60
+ intervalRef.current = null;
61
+ }
62
+
63
+ setRunning(false);
64
+ onFinishRef.current?.();
65
+ return 0;
66
+ }
67
+
68
+ return current - 1;
69
+ });
70
+ }, 1000);
71
+
72
+ return () => {
73
+ if (intervalRef.current) {
74
+ clearInterval(intervalRef.current);
75
+ intervalRef.current = null;
76
+ }
77
+ };
78
+ }, [running]);
79
+
80
+ React.useEffect(
81
+ () => () => {
82
+ if (intervalRef.current) {
83
+ clearInterval(intervalRef.current);
84
+ }
85
+ },
86
+ []
87
+ );
88
+
89
+ return {
90
+ remainingSeconds,
91
+ canResend: remainingSeconds === 0 && !running,
92
+ running,
93
+ start,
94
+ stop,
95
+ reset,
96
+ };
97
+ }
@@ -0,0 +1,279 @@
1
+ import * as React from 'react';
2
+ import type { NativeSyntheticEvent, TextInput, TextInputKeyPressEventData } from 'react-native';
3
+
4
+ import type { UseOtpInputOptions, UseOtpInputReturn } from '../types';
5
+ import { createOtpArray } from '../utils/createOtpArray';
6
+ import { sanitizeOtp } from '../utils/sanitizeOtp';
7
+
8
+ function normalizeOtpCells(values: string[], length: number): string[] {
9
+ return Array.from({ length }, (_, index) => sanitizeOtp(values[index] ?? '', 1));
10
+ }
11
+
12
+ function getReplacementDigit(text: string, currentDigit: string): string {
13
+ const digits = sanitizeOtp(text, text.length);
14
+ const differentDigit = digits.split('').find((digit) => digit !== currentDigit);
15
+
16
+ return differentDigit ?? digits[digits.length - 1] ?? '';
17
+ }
18
+
19
+ function getSequentialFocusIndex(values: string[], length: number): number {
20
+ const firstEmptyIndex = values.findIndex((value) => !value);
21
+
22
+ return firstEmptyIndex === -1 ? length - 1 : firstEmptyIndex;
23
+ }
24
+
25
+ function getLastFilledIndex(values: string[]): number {
26
+ for (let index = values.length - 1; index >= 0; index -= 1) {
27
+ if (values[index]) {
28
+ return index;
29
+ }
30
+ }
31
+
32
+ return -1;
33
+ }
34
+
35
+ function replaceCellAndClearAfter(values: string[], digit: string, index: number): string[] {
36
+ return values.map((value, valueIndex) => {
37
+ if (valueIndex < index) {
38
+ return value;
39
+ }
40
+
41
+ if (valueIndex === index) {
42
+ return digit;
43
+ }
44
+
45
+ return '';
46
+ });
47
+ }
48
+
49
+ export function useOtpInput({
50
+ length = 6,
51
+ value,
52
+ defaultValue = '',
53
+ onChangeCode,
54
+ onComplete,
55
+ allowCellSelection = true,
56
+ }: UseOtpInputOptions = {}): UseOtpInputReturn {
57
+ const safeLength = Math.max(1, length);
58
+ const isControlled = value !== undefined;
59
+ const inputRefs = React.useRef<Array<TextInput | null>>([]);
60
+ const completedCodeRef = React.useRef<string | null>(null);
61
+ const [cellValues, setCellValues] = React.useState(() =>
62
+ createOtpArray(sanitizeOtp(value ?? defaultValue, safeLength), safeLength)
63
+ );
64
+
65
+ const [focusedIndex, setFocusedIndex] = React.useState(0);
66
+ const code = cellValues.join('');
67
+ const cellValuesRef = React.useRef(cellValues);
68
+
69
+ React.useEffect(() => {
70
+ cellValuesRef.current = cellValues;
71
+ }, [cellValues]);
72
+
73
+ React.useEffect(() => {
74
+ if (!isControlled) {
75
+ return;
76
+ }
77
+
78
+ const nextCode = sanitizeOtp(value ?? '', safeLength);
79
+
80
+ if (nextCode !== code) {
81
+ setCellValues(createOtpArray(nextCode, safeLength));
82
+ }
83
+ }, [code, isControlled, safeLength, value]);
84
+
85
+ React.useEffect(() => {
86
+ setCellValues((currentValues) => {
87
+ if (currentValues.length === safeLength) {
88
+ return currentValues;
89
+ }
90
+
91
+ return normalizeOtpCells(currentValues, safeLength);
92
+ });
93
+ }, [safeLength]);
94
+
95
+ const focusCell = React.useCallback(
96
+ (index: number) => {
97
+ const requestedIndex = allowCellSelection
98
+ ? index
99
+ : getSequentialFocusIndex(cellValuesRef.current, safeLength);
100
+ const nextIndex = Math.min(Math.max(requestedIndex, 0), safeLength - 1);
101
+ setFocusedIndex(nextIndex);
102
+ inputRefs.current[nextIndex]?.focus();
103
+ },
104
+ [allowCellSelection, safeLength]
105
+ );
106
+
107
+ const commitCellValues = React.useCallback(
108
+ (nextValues: string[]) => {
109
+ const normalizedValues = normalizeOtpCells(nextValues, safeLength);
110
+ const nextCode = normalizedValues.join('');
111
+
112
+ setCellValues(normalizedValues);
113
+ onChangeCode?.(nextCode);
114
+
115
+ if (nextCode.length !== safeLength) {
116
+ completedCodeRef.current = null;
117
+ }
118
+ },
119
+ [onChangeCode, safeLength]
120
+ );
121
+
122
+ const replaceCell = React.useCallback(
123
+ (digit: string, index: number) => {
124
+ const nextValues = [...cellValues];
125
+ nextValues[index] = digit;
126
+ commitCellValues(nextValues);
127
+ },
128
+ [cellValues, commitCellValues]
129
+ );
130
+
131
+ const handleChangeText = React.useCallback(
132
+ (text: string, index: number) => {
133
+ const pastedText = sanitizeOtp(text, safeLength);
134
+ const targetIndex = allowCellSelection
135
+ ? index
136
+ : getSequentialFocusIndex(cellValues, safeLength);
137
+
138
+ if (!pastedText) {
139
+ if (allowCellSelection && text === '' && cellValues[index]) {
140
+ replaceCell('', index);
141
+ }
142
+ return;
143
+ }
144
+
145
+ if (pastedText.length === safeLength) {
146
+ commitCellValues(createOtpArray(pastedText, safeLength));
147
+ requestAnimationFrame(() => focusCell(safeLength - 1));
148
+ return;
149
+ }
150
+
151
+ if (!allowCellSelection && code.length === safeLength) {
152
+ return;
153
+ }
154
+
155
+ if (pastedText.length > 1 && cellValues[targetIndex]) {
156
+ const replacementDigit = getReplacementDigit(pastedText, cellValues[targetIndex]);
157
+ const nextValues = allowCellSelection
158
+ ? replaceCellAndClearAfter(cellValues, replacementDigit, targetIndex)
159
+ : (() => {
160
+ const values = [...cellValues];
161
+ values[targetIndex] = replacementDigit;
162
+ return values;
163
+ })();
164
+
165
+ commitCellValues(nextValues);
166
+
167
+ if (targetIndex < safeLength - 1) {
168
+ requestAnimationFrame(() => focusCell(targetIndex + 1));
169
+ }
170
+
171
+ return;
172
+ }
173
+
174
+ if (code.length === safeLength && !cellValues[targetIndex]) {
175
+ return;
176
+ }
177
+
178
+ if (pastedText.length === 1) {
179
+ const nextValues =
180
+ allowCellSelection && cellValues[targetIndex]
181
+ ? replaceCellAndClearAfter(cellValues, pastedText, targetIndex)
182
+ : (() => {
183
+ const values = [...cellValues];
184
+ values[targetIndex] = pastedText;
185
+ return values;
186
+ })();
187
+
188
+ commitCellValues(nextValues);
189
+
190
+ if (targetIndex < safeLength - 1) {
191
+ requestAnimationFrame(() => focusCell(targetIndex + 1));
192
+ }
193
+
194
+ return;
195
+ }
196
+
197
+ const nextValues = [...cellValues];
198
+
199
+ pastedText.split('').forEach((digit, offset) => {
200
+ const nextIndex = targetIndex + offset;
201
+
202
+ if (nextIndex < safeLength) {
203
+ nextValues[nextIndex] = digit;
204
+ }
205
+ });
206
+
207
+ commitCellValues(nextValues);
208
+ requestAnimationFrame(() =>
209
+ focusCell(Math.min(targetIndex + pastedText.length, safeLength - 1))
210
+ );
211
+ },
212
+ [
213
+ allowCellSelection,
214
+ cellValues,
215
+ code.length,
216
+ commitCellValues,
217
+ focusCell,
218
+ replaceCell,
219
+ safeLength,
220
+ ]
221
+ );
222
+
223
+ const handleKeyPress = React.useCallback(
224
+ (
225
+ event: NativeSyntheticEvent<TextInputKeyPressEventData>,
226
+ index: number
227
+ ) => {
228
+ if (event.nativeEvent.key !== 'Backspace') {
229
+ return;
230
+ }
231
+
232
+ if (!allowCellSelection) {
233
+ const lastFilledIndex = getLastFilledIndex(cellValues);
234
+
235
+ if (lastFilledIndex >= 0) {
236
+ replaceCell('', lastFilledIndex);
237
+ requestAnimationFrame(() => focusCell(lastFilledIndex));
238
+ }
239
+
240
+ return;
241
+ }
242
+
243
+ if (cellValues[index]) {
244
+ replaceCell('', index);
245
+ return;
246
+ }
247
+
248
+ if (index > 0) {
249
+ requestAnimationFrame(() => focusCell(index - 1));
250
+ }
251
+ },
252
+ [allowCellSelection, cellValues, focusCell, replaceCell]
253
+ );
254
+
255
+ const clear = React.useCallback(() => {
256
+ commitCellValues(createOtpArray('', safeLength));
257
+ requestAnimationFrame(() => focusCell(0));
258
+ }, [commitCellValues, focusCell, safeLength]);
259
+
260
+ React.useEffect(() => {
261
+ if (code.length === safeLength && completedCodeRef.current !== code) {
262
+ completedCodeRef.current = code;
263
+ onComplete?.(code);
264
+ requestAnimationFrame(() => focusCell(safeLength - 1));
265
+ }
266
+ }, [code, focusCell, onComplete, safeLength]);
267
+
268
+ return {
269
+ code,
270
+ cellValues,
271
+ focusedIndex,
272
+ inputRefs,
273
+ setFocusedIndex,
274
+ handleChangeText,
275
+ handleKeyPress,
276
+ focusCell,
277
+ clear,
278
+ };
279
+ }
package/src/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ export { OtpInput } from './components/OtpInput';
2
+ export { OtpInputCell } from './components/OtpInput';
3
+ export { OtpResendTimer } from './components/OtpResendTimer';
4
+ export { useOtpInput } from './hooks/useOtpInput';
5
+ export { useCountdown } from './hooks/useCountdown';
6
+ export { sanitizeOtp } from './utils/sanitizeOtp';
7
+ export { createOtpArray } from './utils/createOtpArray';
8
+ export type {
9
+ OtpInputCellProps,
10
+ OtpInputProps,
11
+ OtpResendTimerProps,
12
+ UseCountdownOptions,
13
+ UseCountdownReturn,
14
+ UseOtpInputOptions,
15
+ UseOtpInputReturn,
16
+ } from './types';
@@ -0,0 +1,110 @@
1
+ import type React from 'react';
2
+ import type {
3
+ NativeSyntheticEvent,
4
+ TextInput,
5
+ TextInputKeyPressEventData,
6
+ TextInputProps,
7
+ TextStyle,
8
+ ViewStyle,
9
+ } from 'react-native';
10
+
11
+ export type OtpInputProps = {
12
+ length?: number;
13
+ value?: string;
14
+ defaultValue?: string;
15
+ onChangeCode?: (code: string) => void;
16
+ onComplete?: (code: string) => void;
17
+ allowCellSelection?: boolean;
18
+ autoFocus?: boolean;
19
+ disabled?: boolean;
20
+ error?: boolean;
21
+ secureTextEntry?: boolean;
22
+ placeholder?: string;
23
+ keyboardType?: TextInputProps['keyboardType'];
24
+ containerStyle?: ViewStyle;
25
+ cellStyle?: ViewStyle;
26
+ focusedCellStyle?: ViewStyle;
27
+ filledCellStyle?: ViewStyle;
28
+ errorCellStyle?: ViewStyle;
29
+ textStyle?: TextStyle;
30
+ testID?: string;
31
+ };
32
+
33
+ export type OtpInputCellProps = {
34
+ value: string;
35
+ index: number;
36
+ length?: number;
37
+ focused?: boolean;
38
+ filled?: boolean;
39
+ error?: boolean;
40
+ disabled?: boolean;
41
+ secureTextEntry?: boolean;
42
+ placeholder?: string;
43
+ keyboardType?: TextInputProps['keyboardType'];
44
+ cellStyle?: ViewStyle;
45
+ focusedCellStyle?: ViewStyle;
46
+ filledCellStyle?: ViewStyle;
47
+ errorCellStyle?: ViewStyle;
48
+ textStyle?: TextStyle;
49
+ testID?: string;
50
+ inputRef?: (input: TextInput | null) => void;
51
+ onFocus?: (index: number) => void;
52
+ onChangeText: (text: string, index: number) => void;
53
+ onKeyPress: (
54
+ event: NativeSyntheticEvent<TextInputKeyPressEventData>,
55
+ index: number
56
+ ) => void;
57
+ };
58
+
59
+ export type OtpResendTimerProps = {
60
+ seconds?: number;
61
+ autoStart?: boolean;
62
+ onResend?: () => void;
63
+ onFinish?: () => void;
64
+ renderText?: (remainingSeconds: number, canResend: boolean) => React.ReactNode;
65
+ resendLabel?: string;
66
+ countdownLabel?: string;
67
+ disabled?: boolean;
68
+ textStyle?: TextStyle;
69
+ resendTextStyle?: TextStyle;
70
+ testID?: string;
71
+ };
72
+
73
+ export type UseOtpInputOptions = {
74
+ length?: number;
75
+ value?: string;
76
+ defaultValue?: string;
77
+ onChangeCode?: (code: string) => void;
78
+ onComplete?: (code: string) => void;
79
+ allowCellSelection?: boolean;
80
+ };
81
+
82
+ export type UseOtpInputReturn = {
83
+ code: string;
84
+ cellValues: string[];
85
+ focusedIndex: number;
86
+ inputRefs: React.MutableRefObject<Array<TextInput | null>>;
87
+ setFocusedIndex: React.Dispatch<React.SetStateAction<number>>;
88
+ handleChangeText: (text: string, index: number) => void;
89
+ handleKeyPress: (
90
+ event: NativeSyntheticEvent<TextInputKeyPressEventData>,
91
+ index: number
92
+ ) => void;
93
+ focusCell: (index: number) => void;
94
+ clear: () => void;
95
+ };
96
+
97
+ export type UseCountdownOptions = {
98
+ seconds?: number;
99
+ autoStart?: boolean;
100
+ onFinish?: () => void;
101
+ };
102
+
103
+ export type UseCountdownReturn = {
104
+ remainingSeconds: number;
105
+ canResend: boolean;
106
+ running: boolean;
107
+ start: () => void;
108
+ stop: () => void;
109
+ reset: () => void;
110
+ };
@@ -0,0 +1,3 @@
1
+ export function createOtpArray(code: string, length: number): string[] {
2
+ return Array.from({ length: Math.max(0, length) }, (_, index) => code[index] ?? '');
3
+ }
@@ -0,0 +1,3 @@
1
+ export function sanitizeOtp(rawInput: string, length: number): string {
2
+ return rawInput.replace(/\D/g, '').slice(0, Math.max(0, length));
3
+ }