InputOTP
One-time code field: one hidden input drives N cells; numeric keyboard, SMS/Keychain autofill, paste, onComplete, error/success status and secure.
Light
Dark
Your app is ready.
Inter body text, Inter headings.
a0: Vega, neutral, neutral accent, default radius, Inter.Change PresetInstallation
npx nativecn-cli@latest add input-otpAlso installs: theme, text, form-field-context, focus-chain, use-controllable-state, announce.
Usage
InputOTP demo
A 6-digit verification code, checked when complete, with error and success.
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>
);
}InputOTP demo
A 6-digit verification code, checked when complete, with error and success.
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
The Vega Style's code, exactly as add copies it into your app.
{components}/input-otp.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>
);
}{components}/input-otp.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.scaleValue(6) }, flexDirection: "row" },
cell: {
...{
width: t.scaleValue(40),
height: t.scaleValue(48),
borderRadius: t.radius.sm,
},
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>
);
}
