Skip to content
All components

Free reference

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.