# InputOTP

> One-time code field: one hidden input drives N cells; numeric keyboard, SMS/Keychain autofill, paste, onComplete, error/success status and secure.

Component · forms. Registry item `input-otp`, shown in the vega Style. JSON: https://nativecn.dev/r/styles/vega/input-otp.json

## Installation

```sh
npx nativecn-cli@latest add input-otp
```

Also installs: [theme](https://nativecn.dev/docs/theme/theme), [text](https://nativecn.dev/docs/components/text), [form-field-context](https://nativecn.dev/docs/primitives/form-field-context), [focus-chain](https://nativecn.dev/docs/primitives/focus-chain), [use-controllable-state](https://nativecn.dev/docs/primitives/use-controllable-state), [announce](https://nativecn.dev/docs/primitives/announce).

## Usage

### InputOTP demo

A 6-digit verification code, checked when complete, with error and success.

```tsx
import { useState } from "react";
import { View } from "react-native";

import { InputOTP, type InputOTPStatus } from "@/components/input-otp";
import { Text } from "@/components/text";
import { createStyles } from "@/theme";

const useStyles = createStyles((t) => ({ root: { gap: t.spacing[3] } }));

/** A 6-digit verification code: checked when complete; typing again clears the status. */
export function InputOTPDemo() {
  const styles = useStyles();
  const [code, setCode] = useState("");
  const [status, setStatus] = useState<InputOTPStatus>();

  return (
    <View style={styles.root}>
      <Text variant="label">Verification code</Text>
      <InputOTP
        aria-label="Verification code"
        value={code}
        onChangeText={(next) => {
          setCode(next);
          setStatus(undefined);
        }}
        onComplete={(full) => setStatus(full === "123456" ? "success" : "error")}
        status={status}
      />
      <Text variant="caption" color={status === "error" ? "destructive" : "mutedForeground"}>
        {status === "error" ? "That code is not right. Try 123456." : "Enter the code we sent you."}
      </Text>
    </View>
  );
}
```

## Props

| Prop | Type and description |
|---|---|
| `length` | number of digits (default 6) |
| `value` | string (controlled) |
| `defaultValue` | string (uncontrolled, default "") |
| `onChangeText` | (code: string) => void: digits only, on every change including a paste |
| `onComplete` | (code: string) => void: once each time the last cell is filled |
| `status` | 'error' \| 'success': colours every cell, announced once; the screen clears it |
| `secure` | boolean: dots instead of digits, hidden from screen readers (default false) |
| `disabled` | boolean: not editable, dimmed, skipped by FocusChain |
| `ref` | Ref<TextInput>: the hidden input (focus, blur, clear) |
| `style` | ViewStyle, layout only, merged last onto the root |
| `...TextInputProps` | other TextInput props pass through to the hidden input (autoFocus, onFocus, aria-label, accessibilityHint, ...) |

## Variants

- `status`: `error`, `success`

## Accessibility

- Screen readers read one field, "Code, 6 digits": the name is aria-label, else the FormField label, else "Code", plus the digit count. The cells are hidden.
- Inside a FormField the error, or else the description, is its hint.
- A new status is announced once ("Code: error"), unless a FormField is already announcing its error.
- With secure, the cells show dots and the hidden input is a secure text entry.
- When the row of cells is under 48, the root extends the tap area to 48 and a tap there focuses the field.
- disabled sets aria-disabled and blocks typing.

## Notes

One hidden TextInput drives the cells, so typing, deleting, one-time-code autofill (iOS Keychain/SMS, Android SMS) and paste behave like one field. Non-digits are dropped, so a pasted "123 456" fills every cell. Inside FormField it takes the label, error and disabled; inside FocusChain it joins as one field. Cell size, radius and gap come from the input-otp.cell and input-otp.root Style Slots. Precedence: disabled > status.

## Source

### {components}/input-otp.tsx

```tsx
import { useCallback, useEffect, useRef, useState, type Ref } from "react";
import {
  Pressable as RNPressable,
  TextInput,
  View,
  type Insets,
  type StyleProp,
  type TextInputProps,
  type ViewStyle,
} from "react-native";

import { useFocusChainField } from "@/components/primitives/focus-chain";
import { useFormField } from "@/components/primitives/form-field-context";
import { Text } from "@/components/text";
import { useControllableState } from "@/hooks/use-controllable-state";
import { createStyles, useTheme } from "@/theme";
import { announce } from "@/utils/announce";

export type InputOTPStatus = "error" | "success";

/** The border Colour Role of a cell in each state. */
const cellBorders = {
  idle: "input",
  active: "ring",
  error: "destructive",
  success: "success",
} as const;

/** The character shown in a filled cell while `secure` is on. */
const SECURE_DOT = "•";

export type InputOTPProps = Omit<
  TextInputProps,
  | "value"
  | "defaultValue"
  | "onChangeText"
  | "maxLength"
  | "secureTextEntry"
  | "multiline"
  | "style"
  | "editable"
> & {
  /** The number of digits (default `6`). */
  length?: number;
  /** The controlled code. */
  value?: string;
  /** The starting code while uncontrolled (default `""`). */
  defaultValue?: string;
  /** Called with the digits-only code on every change, including a paste. */
  onChangeText?: (code: string) => void;
  /** Called once with the code each time the last cell is filled. */
  onComplete?: (code: string) => void;
  /** Colours the cells and announces the change. The screen sets and clears it. */
  status?: InputOTPStatus;
  /** Shows a dot instead of each digit, and hides the code from screen readers. */
  secure?: boolean;
  /** Blocks typing and is announced as disabled. */
  disabled?: boolean;
  /** Layout only, merged last onto the root. */
  style?: StyleProp<ViewStyle>;
  /** The hidden TextInput that drives the cells (`focus()`, `blur()`, `clear()`). */
  ref?: Ref<TextInput>;
};

/** Keep digits only, at most `length` of them (a pasted "123 456" becomes "123456"). */
/**
 * The extra tap area around the row of cells so it reaches `min` (the 48 touch target) on both
 * axes. The hidden TextInput covers the row but has no hitSlop of its own.
 */
export function cellsHitSlop(
  cellWidth: number,
  cellHeight: number,
  gap: number,
  length: number,
  min: number,
): Insets | undefined {
  const rowWidth = length * cellWidth + Math.max(0, length - 1) * gap;
  const x = Math.max(0, (min - rowWidth) / 2);
  const y = Math.max(0, (min - cellHeight) / 2);
  return x === 0 && y === 0 ? undefined : { top: y, bottom: y, left: x, right: x };
}

export function sanitizeCode(text: string, length: number): string {
  return text.replace(/\D/g, "").slice(0, length);
}

const useStyles = createStyles((t) => ({
  root: { alignSelf: "flex-start" },
  cells: { ...{ gap: t.spacing[2] }, flexDirection: "row" },
  cell: {
    ...{
    width: t.scaleValue(48),
    height: t.scaleValue(56),
    borderRadius: t.radius.md,
  },
    alignItems: "center",
    justifyContent: "center",
    borderCurve: "continuous",
    borderWidth: t.borderWidth.default,
    backgroundColor: t.colors.background,
  },
  idle: { borderColor: t.colors[cellBorders.idle] },
  active: { borderColor: t.colors[cellBorders.active] },
  error: { borderColor: t.colors[cellBorders.error] },
  success: { borderColor: t.colors[cellBorders.success] },
  // The real TextInput covers the cells, so a tap focuses it and a long press offers Paste. Its
  // text and caret are transparent (not `opacity: 0`, which hides it from VoiceOver).
  input: {
    position: "absolute",
    top: 0,
    right: 0,
    bottom: 0,
    left: 0,
    color: "transparent",
    backgroundColor: "transparent",
    borderWidth: 0,
    padding: 0,
  },
  disabled: { opacity: t.opacity.disabled },
}));

/**
 * A one-time code field: one hidden TextInput drives `length` visible cells, so typing, deleting,
 * SMS/Keychain autofill (`one-time-code`) and paste all behave like one field. Numeric keyboard;
 * `onComplete` fires when the last cell is filled. Inside FormField it takes its label, status and
 * disabled state; inside FocusChain it joins as one field. Screen readers read one field:
 * "Code, 6 digits". Precedence: `disabled` > `status`.
 */
export function InputOTP({
  length = 6,
  value,
  defaultValue = "",
  onChangeText,
  onComplete,
  status,
  secure = false,
  disabled,
  style,
  ref,
  testID,
  onFocus,
  onBlur,
  returnKeyType,
  onSubmitEditing,
  submitBehavior,
  "aria-label": ariaLabel,
  accessibilityHint,
  ...props
}: InputOTPProps) {
  const field = useFormField();
  const styles = useStyles();
  const { minTouchTarget } = useTheme();

  const isDisabled = disabled ?? field?.disabled ?? false;
  const shownStatus = isDisabled ? undefined : (status ?? field?.status);

  const [raw, setCode] = useControllableState({
    value,
    defaultValue,
    onChange: onChangeText,
  });
  const code = sanitizeCode(raw, length);
  const [focused, setFocused] = useState(false);

  const inputRef = useRef<TextInput | null>(null);
  const setRefs = useCallback(
    (node: TextInput | null) => {
      inputRef.current = node;
      if (typeof ref === "function") ref(node);
      else if (ref) ref.current = node;
    },
    [ref],
  );

  const chain = useFocusChainField(inputRef, {
    disabled: isDisabled,
    returnKeyType,
    onSubmitEditing,
    submitBehavior,
  });

  const handleChangeText = (text: string) => {
    if (isDisabled) return;
    const next = sanitizeCode(text, length);
    if (next === code) return;
    setCode(next);
    if (next.length === length) onComplete?.(next);
  };

  const handleFocus: TextInputProps["onFocus"] = (event) => {
    setFocused(true);
    onFocus?.(event);
  };
  const handleBlur: TextInputProps["onBlur"] = (event) => {
    setFocused(false);
    onBlur?.(event);
  };

  // The spoken name: "Code, 6 digits" (or the FormField label, e.g. "Verification code, required").
  const name = ariaLabel ?? field?.accessibilityProps["aria-label"] ?? "Code";
  const spokenName = `${name}, ${length} digits`;
  const hint = accessibilityHint ?? field?.accessibilityProps.accessibilityHint;

  // Announce a new status once. A FormField already announces its own error text.
  const announced = useRef<InputOTPStatus | undefined>(undefined);
  useEffect(() => {
    if (shownStatus && shownStatus !== announced.current && !field?.error) {
      announce(`${name}: ${shownStatus}`);
    }
    announced.current = shownStatus;
  }, [shownStatus, name, field?.error]);

  const activeIndex = focused && !isDisabled ? Math.min(code.length, length - 1) : -1;

  // Known before layout (Style Slots), so the tap area reaches 48 from the first frame.
  const hitSlop = cellsHitSlop(
    styles.cell.width as number,
    styles.cell.height as number,
    (styles.cells.gap as number | undefined) ?? 0,
    length,
    minTouchTarget,
  );

  return (
    // A tap within the root's hitSlop focuses the hidden TextInput (taps on the cells reach it
    // directly). Screen readers skip the root and read the TextInput as one field.
    <RNPressable
      testID={testID}
      accessible={false}
      focusable={false}
      importantForAccessibility="no"
      disabled={isDisabled}
      hitSlop={hitSlop}
      onPress={isDisabled ? undefined : () => inputRef.current?.focus()}
      style={[styles.root, isDisabled ? styles.disabled : null, style]}
    >
      <View
        style={styles.cells}
        aria-hidden
        importantForAccessibility="no-hide-descendants"
        pointerEvents="none"
      >
        {Array.from({ length }, (_, i) => {
          const char = code[i];
          const border = shownStatus ?? (i === activeIndex ? "active" : "idle");
          return (
            <View
              key={i}
              testID={testID ? `${testID}-cell-${i}` : undefined}
              style={[styles.cell, styles[border]]}
            >
              {char != null ? <Text variant="h3">{secure ? SECURE_DOT : char}</Text> : null}
            </View>
          );
        })}
      </View>
      <TextInput
        ref={setRefs}
        testID={testID ? `${testID}-input` : undefined}
        value={code}
        onChangeText={handleChangeText}
        editable={!isDisabled}
        keyboardType="number-pad"
        inputMode="numeric"
        autoComplete="one-time-code"
        textContentType="oneTimeCode"
        secureTextEntry={secure}
        autoCorrect={false}
        spellCheck={false}
        caretHidden
        contextMenuHidden={false}
        selectionColor="transparent"
        underlineColorAndroid="transparent"
        aria-label={spokenName}
        aria-disabled={isDisabled}
        accessibilityHint={hint}
        onFocus={handleFocus}
        onBlur={handleBlur}
        {...chain}
        {...props}
        style={styles.input}
      />
    </RNPressable>
  );
}
```
