# Input OTP

> A secure input component for one-time passwords and verification codes.

**BNA UI** — a React Native / Expo component library.
These components render through `react-native`, not the DOM: there are no HTML
elements, no Tailwind classes and no Radix primitives. Source is copied into your
project and imported through `@/components/ui/*`, `@/components/charts/*`,
`@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than
hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`)
come from `@/theme/globals`.

- Docs: https://ui.ahmedbna.com/docs/components/input-otp
- Markdown: https://ui.ahmedbna.com/docs/components/input-otp.md
- Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/input-otp.json
- Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/input-otp.json
- Install: `npx bna-ui add input-otp`
- npm dependencies: `expo-haptics`
- Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `useHaptics`, `globals`, `text`
- Preview recording: https://demo.ahmedbna.com/0167-input-otp-demo.MP4

---

**Example:** A basic OTP input with 6 digits

```tsx
// components/demo/input-otp/input-otp-demo.tsx
import { InputOTP } from '@/components/ui/input-otp';
import React, { useState } from 'react';

export function InputOTPDemo() {
  const [otp, setOtp] = useState('');

  return (
    <InputOTP
      length={6}
      value={otp}
      onChangeText={setOtp}
      onComplete={(value) => {
        console.log('OTP Complete:', value);
      }}
    />
  );
}
```

## Installation

### CLI

```bash
npx bna-ui add input-otp
```

### Manual

**1.** Copy and paste the following code into your project.

```tsx
// components/ui/input-otp.tsx
import { Text } from '@/components/ui/text';
import { useColor } from '@/hooks/useColor';
import { useHaptics } from '@/hooks/useHaptics';
import { CORNERS, FONT_SIZE } from '@/theme/globals';
import React, {
  forwardRef,
  useCallback,
  useImperativeHandle,
  useRef,
  useState,
} from 'react';
import {
  NativeSyntheticEvent,
  Pressable,
  TextInput,
  TextInputKeyPressEventData,
  TextInputProps,
  TextStyle,
  View,
  ViewStyle,
} from 'react-native';

export interface InputOTPProps extends Omit<
  TextInputProps,
  'style' | 'value' | 'onChangeText'
> {
  /** Number of OTP digits */
  length?: number;
  /** Current OTP value */
  value?: string;
  /** Called when OTP value changes */
  onChangeText?: (value: string) => void;
  /** Called when OTP is complete */
  onComplete?: (value: string) => void;
  /** Error message to display */
  error?: string;
  /** Disabled state */
  disabled?: boolean;
  /** Container style */
  containerStyle?: ViewStyle;
  /** Individual slot style */
  slotStyle?: ViewStyle;
  /** Error style */
  errorStyle?: TextStyle;
  /** Whether to mask the input (show dots instead of numbers) */
  masked?: boolean;
  /** Separator component between slots */
  separator?: React.ReactNode;
  /** Whether to show cursor in active slot */
  showCursor?: boolean;
  /** Whether to trigger haptic feedback when the code is complete */
  haptic?: boolean;
}

export interface InputOTPRef {
  focus: () => void;
  blur: () => void;
  clear: () => void;
  getValue: () => string;
}

export const InputOTP = forwardRef<InputOTPRef, InputOTPProps>(
  (
    {
      length = 6,
      value = '',
      onChangeText,
      onComplete,
      error,
      disabled = false,
      containerStyle,
      slotStyle,
      errorStyle,
      masked = false,
      separator,
      showCursor = true,
      haptic = true,
      onFocus,
      onBlur,
      ...textInputProps
    },
    ref
  ) => {
    const [isFocused, setIsFocused] = useState(false);
    const [activeIndex, setActiveIndex] = useState(0);
    const inputRef = useRef<TextInput>(null);
    const feedback = useHaptics(haptic);

    // Theme colors
    const cardColor = useColor('card');
    const textColor = useColor('text');
    const muted = useColor('textMuted');
    const borderColor = useColor('border');
    const primary = useColor('primary');
    const danger = useColor('red');
    const background = useColor('background');

    // Normalize value to ensure it doesn't exceed length
    const normalizedValue = value.slice(0, length);

    // Calculate active index based on current value
    const currentActiveIndex = Math.min(normalizedValue.length, length - 1);

    // Expose methods via ref
    useImperativeHandle(ref, () => ({
      focus: () => inputRef.current?.focus(),
      blur: () => inputRef.current?.blur(),
      clear: () => {
        onChangeText?.('');
        setActiveIndex(0);
      },
      getValue: () => normalizedValue,
    }));

    const handleChangeText = useCallback(
      (text: string) => {
        // Only allow numeric input
        const cleanText = text.replace(/[^0-9]/g, '');
        const limitedText = cleanText.slice(0, length);

        onChangeText?.(limitedText);
        setActiveIndex(Math.min(limitedText.length, length - 1));

        // Call onComplete when OTP is fully entered.
        // Deliberately the only haptic here: the system keyboard already emits
        // its own key click, so a per-keystroke buzz would double up on the one
        // interaction the user repeats `length` times.
        if (limitedText.length === length) {
          feedback('success');
          onComplete?.(limitedText);
        }
      },
      [length, onChangeText, onComplete, feedback]
    );

    const handleKeyPress = useCallback(
      (e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
        const { key } = e.nativeEvent;

        if (key === 'Backspace' && normalizedValue.length > 0) {
          const newValue = normalizedValue.slice(0, -1);
          onChangeText?.(newValue);
          setActiveIndex(Math.max(0, newValue.length));
        }
      },
      [normalizedValue, onChangeText]
    );

    const handleFocus = useCallback(
      (e: any) => {
        setIsFocused(true);
        setActiveIndex(normalizedValue.length);
        onFocus?.(e);
      },
      [normalizedValue.length, onFocus]
    );

    const handleBlur = useCallback(
      (e: any) => {
        setIsFocused(false);
        onBlur?.(e);
      },
      [onBlur]
    );

    const handleSlotPress = useCallback(() => {
      if (!disabled) {
        inputRef.current?.focus();
      }
    }, [disabled]);

    // Generate slots
    const slots = Array.from({ length }, (_, index) => {
      const hasValue = index < normalizedValue.length;
      const isActive = isFocused && index === currentActiveIndex;
      const displayValue = hasValue
        ? masked
          ? '•'
          : normalizedValue[index]
        : '';

      return (
        <React.Fragment key={index}>
          <Pressable
            onPress={handleSlotPress}
            disabled={disabled}
            accessibilityRole='keyboardkey'
            accessibilityLabel={
              hasValue
                ? `Digit ${index + 1} of ${length}, ${masked ? 'filled' : normalizedValue[index]}`
                : `Digit ${index + 1} of ${length}, empty`
            }
            accessibilityState={{ disabled, selected: isActive }}
            style={[
              {
                width: 58,
                height: 58,
                borderRadius: CORNERS,
                borderWidth: 1,
                borderColor: error
                  ? danger
                  : isActive
                    ? primary
                    : hasValue
                      ? borderColor
                      : borderColor,
                backgroundColor: disabled ? muted + '20' : cardColor,
                justifyContent: 'center',
                alignItems: 'center',
                opacity: disabled ? 0.6 : 1,
              },
              slotStyle,
            ]}
          >
            <Text
              style={{
                fontSize: FONT_SIZE + 2,
                fontWeight: '600',
                color: error ? danger : hasValue ? textColor : muted,
              }}
            >
              {displayValue}
            </Text>

            {/* Cursor */}
            {showCursor && isActive && !hasValue && (
              <View
                style={{
                  position: 'absolute',
                  width: 2,
                  height: 20,
                  backgroundColor: primary,
                  opacity: isFocused ? 1 : 0,
                }}
              />
            )}
          </Pressable>

          {/* Separator */}
          {separator && index < length - 1 && (
            <View style={{ marginHorizontal: 4 }}>{separator}</View>
          )}
        </React.Fragment>
      );
    });

    const renderContent = () => (
      <View style={containerStyle}>
        {/* Hidden TextInput for handling input */}
        <TextInput
          ref={inputRef}
          value={normalizedValue}
          onChangeText={handleChangeText}
          onKeyPress={handleKeyPress}
          onFocus={handleFocus}
          onBlur={handleBlur}
          keyboardType='numeric'
          maxLength={length}
          editable={!disabled}
          selectionColor='transparent'
          textContentType='oneTimeCode'
          autoComplete='one-time-code'
          style={{
            position: 'absolute',
            left: -9999,
            opacity: 0,
          }}
          {...textInputProps}
        />

        {/* OTP Slots */}
        <View
          style={{
            flexDirection: 'row',
            alignItems: 'center',
            justifyContent: 'center',
            gap: separator ? 0 : 8,
          }}
        >
          {slots}
        </View>

        {/* Error Message */}
        {error && (
          <Text
            style={[
              {
                textAlign: 'center',
                marginTop: 8,
                fontSize: 14,
                color: danger,
              },
              errorStyle,
            ]}
          >
            {error}
          </Text>
        )}
      </View>
    );

    return renderContent();
  }
);

InputOTP.displayName = 'InputOTP';

// Optional: Export a preset with separator
export const InputOTPWithSeparator = forwardRef<
  InputOTPRef,
  Omit<InputOTPProps, 'separator'>
>((props, ref) => (
  <InputOTP
    ref={ref}
    separator={
      <Text style={{ fontSize: 18, color: useColor('textMuted') }}>-</Text>
    }
    {...props}
  />
));

InputOTPWithSeparator.displayName = 'InputOTPWithSeparator';
```

**2.** Update the import paths to match your project setup.

## Usage

```tsx
import { InputOTP, InputOTPWithSeparator } from '@/components/ui/input-otp';
```

```tsx
<InputOTP
  length={6}
  value={otp}
  onChangeText={setOtp}
  onComplete={(value) => console.log('OTP Complete:', value)}
/>
```

## Examples

#### Default

**Example:** A basic OTP input with 6 digits

```tsx
// components/demo/input-otp/input-otp-demo.tsx
import { InputOTP } from '@/components/ui/input-otp';
import React, { useState } from 'react';

export function InputOTPDemo() {
  const [otp, setOtp] = useState('');

  return (
    <InputOTP
      length={6}
      value={otp}
      onChangeText={setOtp}
      onComplete={(value) => {
        console.log('OTP Complete:', value);
      }}
    />
  );
}
```

#### Different Lengths

**Example:** OTP inputs with different digit lengths

```tsx
// components/demo/input-otp/input-otp-lengths.tsx
import { InputOTP } from '@/components/ui/input-otp';
import { Text } from '@/components/ui/text';
import { View } from '@/components/ui/view';
import React, { useState } from 'react';

export function InputOTPLengths() {
  const [otp4, setOtp4] = useState('');
  const [otp6, setOtp6] = useState('');

  return (
    <View style={{ gap: 20 }}>
      <View style={{ alignItems: 'center', gap: 8 }}>
        <Text style={{ fontSize: 14, fontWeight: '500' }}>4 Digits</Text>
        <InputOTP length={4} value={otp4} onChangeText={setOtp4} />
      </View>

      <View style={{ alignItems: 'center', gap: 8 }}>
        <Text style={{ fontSize: 14, fontWeight: '500' }}>
          6 Digits (Default)
        </Text>
        <InputOTP length={6} value={otp6} onChangeText={setOtp6} />
      </View>
    </View>
  );
}
```

#### With Separator

**Example:** OTP input with dash separators between digits

```tsx
// components/demo/input-otp/input-otp-separator.tsx
import { InputOTP, InputOTPWithSeparator } from '@/components/ui/input-otp';
import { Text } from '@/components/ui/text';
import { View } from '@/components/ui/view';
import { useColor } from '@/hooks/useColor';
import React, { useState } from 'react';

export function InputOTPSeparator() {
  const [otp1, setOtp1] = useState('');
  const [otp2, setOtp2] = useState('');
  const [otp3, setOtp3] = useState('');

  const muted = useColor('textMuted');

  return (
    <View style={{ gap: 24 }}>
      <View style={{ alignItems: 'center', gap: 8 }}>
        <Text style={{ fontSize: 14, fontWeight: '500' }}>
          With Dash Separator
        </Text>
        <InputOTPWithSeparator length={6} value={otp1} onChangeText={setOtp1} />
      </View>

      <View style={{ alignItems: 'center', gap: 8 }}>
        <Text style={{ fontSize: 14, fontWeight: '500' }}>
          With Dot Separator
        </Text>
        <InputOTP
          length={6}
          value={otp2}
          onChangeText={setOtp2}
          separator={
            <Text style={{ fontSize: 16, color: muted, fontWeight: 'bold' }}>
              •
            </Text>
          }
        />
      </View>

      <View style={{ alignItems: 'center', gap: 8 }}>
        <Text style={{ fontSize: 14, fontWeight: '500' }}>
          With Custom Separator
        </Text>
        <InputOTP
          length={4}
          value={otp3}
          onChangeText={setOtp3}
          separator={
            <View
              style={{
                width: 8,
                height: 2,
                backgroundColor: muted,
                marginHorizontal: 4,
              }}
            />
          }
        />
      </View>
    </View>
  );
}
```

#### Masked Input

**Example:** OTP input that masks digits with dots for security

```tsx
// components/demo/input-otp/input-otp-masked.tsx
import { InputOTP } from '@/components/ui/input-otp';
import { Text } from '@/components/ui/text';
import { View } from '@/components/ui/view';
import React, { useState } from 'react';

export function InputOTPMasked() {
  const [normalOtp, setNormalOtp] = useState('');
  const [maskedOtp, setMaskedOtp] = useState('');

  return (
    <View style={{ gap: 24 }}>
      <View style={{ alignItems: 'center', gap: 8 }}>
        <Text style={{ fontSize: 14, fontWeight: '500' }}>
          Normal (Visible Digits)
        </Text>
        <InputOTP length={6} value={normalOtp} onChangeText={setNormalOtp} />
        {normalOtp && (
          <Text style={{ fontSize: 12, opacity: 0.7 }}>
            Current value: {normalOtp}
          </Text>
        )}
      </View>

      <View style={{ alignItems: 'center', gap: 8 }}>
        <Text style={{ fontSize: 14, fontWeight: '500' }}>
          Masked (Hidden Digits)
        </Text>
        <InputOTP
          length={6}
          value={maskedOtp}
          onChangeText={setMaskedOtp}
          masked={true}
        />
        {maskedOtp && (
          <Text style={{ fontSize: 12, opacity: 0.7 }}>
            Current value: {maskedOtp}
          </Text>
        )}
      </View>
    </View>
  );
}
```

#### Error State

**Example:** OTP input showing error state with validation message

```tsx
// components/demo/input-otp/input-otp-error.tsx
import { Button } from '@/components/ui/button';
import { InputOTP } from '@/components/ui/input-otp';
import { Text } from '@/components/ui/text';
import { View } from '@/components/ui/view';
import React, { useState } from 'react';

export function InputOTPError() {
  const [otp, setOtp] = useState('');
  const [error, setError] = useState('');

  const validateOtp = (value: string) => {
    if (value.length === 6) {
      // Simulate validation - reject if all digits are the same
      if (value === '111111' || value === '000000') {
        setError('Invalid verification code. Please try again.');
      } else {
        setError('');
      }
    } else {
      setError('');
    }
  };

  const handleOtpChange = (value: string) => {
    setOtp(value);
    validateOtp(value);
  };

  const simulateError = () => {
    setError('Verification code has expired. Please request a new one.');
  };

  const clearError = () => {
    setError('');
    setOtp('');
  };

  return (
    <View style={{ gap: 16, alignItems: 'center' }}>
      <View style={{ alignItems: 'center', gap: 8 }}>
        <Text style={{ fontSize: 14, fontWeight: '500' }}>
          Enter Verification Code
        </Text>
        <Text style={{ fontSize: 12, opacity: 0.7, textAlign: 'center' }}>
          Try entering "111111" or "000000" to see error state
        </Text>
      </View>

      <InputOTP
        length={6}
        value={otp}
        onChangeText={handleOtpChange}
        error={error}
        onComplete={(value) => {
          if (!error) {
            console.log('Valid OTP:', value);
          }
        }}
      />

      <View style={{ flexDirection: 'row', gap: 12 }}>
        <Button variant='outline' size='sm' onPress={simulateError}>
          Simulate Error
        </Button>
        <Button variant='outline' size='sm' onPress={clearError}>
          Clear
        </Button>
      </View>
    </View>
  );
}
```

#### Disabled State

**Example:** OTP input in disabled state

```tsx
// components/demo/input-otp/input-otp-disabled.tsx
import { Button } from '@/components/ui/button';
import { InputOTP } from '@/components/ui/input-otp';
import { Text } from '@/components/ui/text';
import { View } from '@/components/ui/view';
import React, { useState } from 'react';

export function InputOTPDisabled() {
  const [otp, setOtp] = useState('123');
  const [disabled, setDisabled] = useState(true);

  return (
    <View style={{ gap: 16, alignItems: 'center' }}>
      <View style={{ alignItems: 'center', gap: 8 }}>
        <Text style={{ fontSize: 14, fontWeight: '500' }}>Disabled State</Text>
        <Text style={{ fontSize: 12, opacity: 0.7, textAlign: 'center' }}>
          Toggle the button below to enable/disable the input
        </Text>
      </View>

      <InputOTP
        length={6}
        value={otp}
        onChangeText={setOtp}
        disabled={disabled}
      />

      <Button
        variant={disabled ? 'default' : 'outline'}
        size='sm'
        onPress={() => setDisabled(!disabled)}
      >
        {disabled ? 'Enable Input' : 'Disable Input'}
      </Button>

      {!disabled && (
        <Text style={{ fontSize: 12, opacity: 0.7 }}>Current value: {otp}</Text>
      )}
    </View>
  );
}
```

#### Custom Styling

**Example:** OTP input with custom colors and styling

```tsx
// components/demo/input-otp/input-otp-styled.tsx
import { InputOTP } from '@/components/ui/input-otp';
import { Text } from '@/components/ui/text';
import { View } from '@/components/ui/view';
import { useColor } from '@/hooks/useColor';
import React, { useState } from 'react';

export function InputOTPStyled() {
  const [otp1, setOtp1] = useState('');
  const [otp2, setOtp2] = useState('');
  const [otp3, setOtp3] = useState('');

  const primary = useColor('primary');
  const success = '#10B981';
  const purple = '#8B5CF6';

  return (
    <View style={{ gap: 24 }}>
      <View style={{ alignItems: 'center', gap: 8 }}>
        <Text style={{ fontSize: 14, fontWeight: '500' }}>Rounded Style</Text>
        <InputOTP
          length={6}
          value={otp1}
          onChangeText={setOtp1}
          slotStyle={{
            borderRadius: 25,
            borderWidth: 2,
            borderColor: primary,
          }}
        />
      </View>

      <View style={{ alignItems: 'center', gap: 8 }}>
        <Text style={{ fontSize: 14, fontWeight: '500' }}>Success Theme</Text>
        <InputOTP
          length={4}
          value={otp2}
          onChangeText={setOtp2}
          slotStyle={{
            borderColor: success,
            backgroundColor: success + '10',
            borderRadius: 8,
          }}
        />
      </View>

      <View style={{ alignItems: 'center', gap: 8 }}>
        <Text style={{ fontSize: 14, fontWeight: '500' }}>Large & Purple</Text>
        <InputOTP
          length={4}
          value={otp3}
          onChangeText={setOtp3}
          slotStyle={{
            width: 70,
            height: 70,
            borderColor: purple,
            borderWidth: 2,
            borderRadius: 12,
            backgroundColor: purple + '05',
          }}
          containerStyle={{
            gap: 12,
          }}
        />
      </View>
    </View>
  );
}
```

#### Without Cursor

**Example:** OTP input without the blinking cursor indicator

```tsx
// components/demo/input-otp/input-otp-no-cursor.tsx
import { InputOTP } from '@/components/ui/input-otp';
import { Text } from '@/components/ui/text';
import { View } from '@/components/ui/view';
import React, { useState } from 'react';

export function InputOTPNoCursor() {
  const [otpWithCursor, setOtpWithCursor] = useState('');
  const [otpWithoutCursor, setOtpWithoutCursor] = useState('');

  return (
    <View style={{ gap: 24 }}>
      <View style={{ alignItems: 'center', gap: 8 }}>
        <Text style={{ fontSize: 14, fontWeight: '500' }}>
          With Cursor (Default)
        </Text>
        <InputOTP
          length={6}
          value={otpWithCursor}
          onChangeText={setOtpWithCursor}
          showCursor={true}
        />
      </View>

      <View style={{ alignItems: 'center', gap: 8 }}>
        <Text style={{ fontSize: 14, fontWeight: '500' }}>Without Cursor</Text>
        <InputOTP
          length={6}
          value={otpWithoutCursor}
          onChangeText={setOtpWithoutCursor}
          showCursor={false}
        />
      </View>

      <Text style={{ fontSize: 12, opacity: 0.7, textAlign: 'center' }}>
        Tap on the inputs above to see the difference in cursor behavior
      </Text>
    </View>
  );
}
```

## API Reference

### InputOTP

The main OTP input component for handling one-time passwords and verification codes.

| Prop             | Type                      | Default | Description                                      |
| ---------------- | ------------------------- | ------- | ------------------------------------------------ |
| `length`         | `number`                  | `6`     | Number of OTP digits to display.                 |
| `value`          | `string`                  | `''`    | Current OTP value.                               |
| `onChangeText`   | `(value: string) => void` | -       | Called when OTP value changes.                   |
| `onComplete`     | `(value: string) => void` | -       | Called when OTP is complete (all digits filled). |
| `error`          | `string`                  | -       | Error message to display below the input.        |
| `disabled`       | `boolean`                 | `false` | Whether the input is disabled.                   |
| `masked`         | `boolean`                 | `false` | Whether to mask digits with dots for security.   |
| `showCursor`     | `boolean`                 | `true`  | Whether to show cursor in the active slot.       |
| `separator`      | `ReactNode`               | -       | Custom separator component between slots.        |
| `containerStyle` | `ViewStyle`               | -       | Additional styles for the container.             |
| `slotStyle`      | `ViewStyle`               | -       | Additional styles for individual digit slots.    |
| `errorStyle`     | `TextStyle`               | -       | Additional styles for the error message.         |

### InputOTPWithSeparator

A preset variant of InputOTP that includes dash separators between digits.

| Prop                                       | Type | Default | Description                                                             |
| ------------------------------------------ | ---- | ------- | ----------------------------------------------------------------------- |
| All props from InputOTP except `separator` | -    | -       | Inherits all InputOTP props except separator which is preset to a dash. |

### InputOTPRef

Reference object that provides programmatic control over the InputOTP component.

| Method     | Type           | Description                    |
| ---------- | -------------- | ------------------------------ |
| `focus`    | `() => void`   | Focuses the input.             |
| `blur`     | `() => void`   | Blurs the input.               |
| `clear`    | `() => void`   | Clears all entered digits.     |
| `getValue` | `() => string` | Returns the current OTP value. |

## Usage with Ref

```tsx
import { useRef } from 'react';
import { InputOTP, InputOTPRef } from '@/components/ui/input-otp';

export function MyComponent() {
  const otpRef = useRef<InputOTPRef>(null);

  const handleClear = () => {
    otpRef.current?.clear();
  };

  const handleFocus = () => {
    otpRef.current?.focus();
  };

  return (
    <InputOTP
      ref={otpRef}
      length={6}
      onComplete={(value) => {
        console.log('OTP entered:', value);
      }}
    />
  );
}
```

## Accessibility

The InputOTP component is built with accessibility in mind:

- The hidden `TextInput` sets `textContentType="oneTimeCode"` and `autoComplete="one-time-code"`, enabling native SMS autofill
- Each digit slot exposes an `accessibilityLabel` announcing its position and filled/empty state
- Error messages are rendered as visible text below the input

## Security Considerations

- Use the `masked` prop when dealing with sensitive verification codes
- Always validate OTP values on the server side
- Consider implementing rate limiting for OTP attempts
- Clear sensitive OTP values from memory when no longer needed
