One-time-code input with N cells, auto-advance, backspace navigation, paste support, animated focus ring and per-cell haptics
components/ui/otp-input/otp-input.tsx@lumioui/core@latest@lumioui/ui@latestreact-native-reanimated@^4.0.0expo-haptics@*Live preview — the real component, driven by the props below.
import { OtpInput } from "@/components/ui/otp-input/otp-input";
export function Example() {
return (
<OtpInput />
);
} import * as React from "react";
import { forwardRef, useRef, useState } from "react";
import { Text, TextInput, View, type TextInputProps } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
withDelay,
withRepeat,
withSpring,
withTiming,
} from "react-native-reanimated";
import { cn, spring, timing, useHaptic, useReducedMotion, useTheme } from "@lumioui/core";
import { iconMap } from "@lumioui/ui";
export type OtpStatus = "default" | "error" | "success";
export interface OtpInputProps extends Omit<TextInputProps, "value" | "onChange" | "onChangeText" | "className"> {
/** Controlled code value (digits/chars entered so far). */
value?: string;
onChange?: (value: string) => void;
/** Fired once when the code reaches `length` characters. */
onComplete?: (code: string) => void;
/** Number of cells. Default 6. */
length?: number;
/** Mask cells like a password field. Default false. */
secure?: boolean;
/** Validation state — error shakes the row, success tints the ring. Default "default". */
status?: OtpStatus;
/** Error message announced to screen readers when status is "error". */
errorMessage?: string;
/** Disabled state — dims the row and blocks focus. Default false. */
disabled?: boolean;
/** Extra classes for the outer wrapper. */
className?: string;
}
/** Cell geometry — mirrors the w-12 / h-14 / gap-2 classes so the gliding
* focus ring can track cell positions with pure math (no measurement). */
const CELL_W = 48;
const CELL_H = 56;
const CELL_STEP = CELL_W + 8;
/** Error shake — a real damped oscillation: the row gets a 10pt kick and the
* shared spring.shake token rings it back to rest. More physical than
* keyframed segments, and short enough that the row reads shake, not wobble. */
const SHAKE_KICK = 10;
function shakeRow(translateX: { value: number }) {
translateX.value = SHAKE_KICK;
translateX.value = withSpring(0, spring.shake);
}
/** Caret blink — matches the iOS UITextField caret period (~530ms). */
const CARET_BLINK = { duration: 530 };
/** Success check draw — the badge pops first, then the check reveals
* left-to-right like a stroke being drawn (120ms lag, then a 250ms wipe). */
const CHECK_DRAW_DELAY = 120;
const CheckGlyph = iconMap.check;
interface CellProps {
char: string;
active: boolean;
filled: boolean;
secure: boolean;
status: OtpStatus;
}
function Cell({ char, active, filled, secure, status }: CellProps) {
const reduce = useReducedMotion();
const { colors } = useTheme();
/** Digit entrance driver — springs 0→1 with overshoot for the pop-in. */
const appear = useSharedValue(char ? 1 : 0);
/** Caret blink driver — oscillates 1↔0.15 while the active cell is empty. */
const caret = useSharedValue(0);
const prevChar = useRef(char);
React.useEffect(() => {
if (char !== prevChar.current) {
if (char) {
// Digit pop-in: scale up through a slight overshoot, quick fade.
// Also fires on digit replacement (autofill correcting a cell).
appear.value = 0.6;
appear.value = reduce ? 1 : withSpring(1, spring.bouncy);
} else {
appear.value = 0; // erase instantly — no exit theater mid-typing
}
prevChar.current = char;
}
}, [char, appear, reduce]);
React.useEffect(() => {
const showCaret = active && !char;
if (reduce) {
caret.value = showCaret ? 1 : 0;
} else if (showCaret) {
caret.value = 1;
caret.value = withRepeat(withTiming(0.15, CARET_BLINK), -1, true);
} else {
caret.value = 0;
}
}, [active, char, caret, reduce]);
const digitStyle = useAnimatedStyle(() => ({
opacity: Math.min(1, appear.value * 2),
// Springs overshoot past 1 — that carry-through is the pop.
transform: [{ scale: 0.55 + appear.value * 0.45 }],
}));
const caretStyle = useAnimatedStyle(() => ({ opacity: caret.value }));
return (
<View
className={cn(
"h-14 w-12 items-center justify-center rounded-xl border bg-card",
status === "error"
? "border-destructive"
: status === "success"
? "border-success"
: active
? "border-ring"
: filled
? "border-border"
: "border-input"
)}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Animated.View style={digitStyle}>
<Text className="text-xl font-semibold text-card-foreground">
{char ? (secure ? "•" : char) : ""}
</Text>
</Animated.View>
{/* Blinking caret in the focused empty cell — the "your digit lands
here" affordance iOS code fields have. */}
<Animated.View
style={[{ position: "absolute", width: 2, height: 22, borderRadius: 1, backgroundColor: colors.primary }, caretStyle]}
pointerEvents="none"
/>
</View>
);
}
/** Success badge — a success-tinted dot that pops onto the trailing cell,
* then draws its check via a left-to-right clip reveal (no SVG dep). */
function SuccessBadge() {
const reduce = useReducedMotion();
const { colors } = useTheme();
const pop = useSharedValue(0);
const draw = useSharedValue(0);
React.useEffect(() => {
if (reduce) {
pop.value = 1;
draw.value = 1;
return;
}
pop.value = withSpring(1, spring.bouncy);
draw.value = withDelay(CHECK_DRAW_DELAY, withTiming(1, timing.normal));
}, [pop, draw, reduce]);
const badgeStyle = useAnimatedStyle(() => ({
transform: [{ scale: pop.value }],
opacity: Math.min(1, pop.value * 2),
}));
const clipStyle = useAnimatedStyle(() => ({ width: draw.value * 12 }));
return (
<Animated.View
style={badgeStyle}
className="absolute -right-2 -top-2 h-5 w-5 items-center justify-center rounded-full bg-success"
pointerEvents="none"
>
<Animated.View style={[{ overflow: "hidden", width: 12 }, clipStyle]}>
{/* Fixed-width inner so the clip wipes the glyph instead of squashing it. */}
<View style={{ width: 12 }} className="items-center justify-center">
<CheckGlyph size={11} color={colors.successForeground} strokeWidth={3} fill="none" />
</View>
</Animated.View>
</Animated.View>
);
}
/**
* OtpInput — PRO. One-time-code input rendered as N cells over a hidden
* TextInput: auto-advance, backspace navigation and paste work natively.
* The focus ring glides between cells, digits pop in, an empty active cell
* shows a blinking caret, errors ring a damped shake, and completion pops a
* drawn-on check. Haptics: selection per char, error on failure, success
* on completion. Expo Go compatible.
*/
export const OtpInput = forwardRef<TextInput, OtpInputProps>(function OtpInput(
{
value,
onChange,
onComplete,
length = 6,
secure = false,
status = "default",
errorMessage,
disabled = false,
className,
...props
},
ref
) {
const haptics = useHaptic();
const reduce = useReducedMotion();
const inputRef = useRef<TextInput | null>(null);
const [focused, setFocused] = useState(false);
const [internal, setInternal] = useState("");
const code = value ?? internal;
const rowX = useSharedValue(0);
const ringX = useSharedValue(0);
const ringOn = useSharedValue(0);
const setCode = (next: string) => {
const trimmed = next.slice(0, length);
if (trimmed.length > code.length) haptics.selection();
if (value === undefined) setInternal(trimmed);
onChange?.(trimmed);
};
// Fire onComplete for typed AND controlled/autofilled completions.
const completedFor = useRef<string | null>(null);
React.useEffect(() => {
if (code.length === length && completedFor.current !== code) {
completedFor.current = code;
haptics.success();
onComplete?.(code);
}
if (code.length < length) completedFor.current = null;
}, [code, length, haptics, onComplete]);
// Error status shakes the row (unless reduced motion) and fires an
// error haptic so the failure is felt, not just seen.
const hadError = useRef(status === "error");
React.useEffect(() => {
if (status === "error" && !hadError.current) {
if (!reduce) shakeRow(rowX);
haptics.error();
}
hadError.current = status === "error";
}, [status, reduce, rowX, haptics]);
const activeIndex = Math.min(code.length, length - 1);
const ringVisible = focused && code.length < length;
// The ring glides cell-to-cell — spring.glide keeps the travel smooth and
// deliberate; the fade is quick so blur never leaves a stray outline.
React.useEffect(() => {
ringX.value = reduce ? activeIndex * CELL_STEP : withSpring(activeIndex * CELL_STEP, spring.glide);
ringOn.value = ringVisible ? withTiming(1, timing.fast) : withTiming(0, timing.fast);
}, [activeIndex, ringVisible, ringX, ringOn, reduce]);
const rowStyle = useAnimatedStyle(() => ({
transform: [{ translateX: rowX.value }],
}));
const ringStyle = useAnimatedStyle(() => ({
transform: [{ translateX: ringX.value }],
opacity: ringOn.value,
}));
const positionText = `${code.length} of ${length} digits entered`;
return (
<View className={cn(disabled && "opacity-60", className)}>
{/* Content-sized centered row by design — cells hug their size so the
group stays centered on narrow and wide screens alike. The inner
wrapper hugs the cells so the gliding ring can anchor to cell 0. */}
<Animated.View style={rowStyle}>
{/* No press-to-focus wrapper: the overlay TextInput below sits on
top of the cells and handles taps/focus itself. */}
<View className="flex-row justify-center" accessible={false}>
<View className="flex-row gap-2">
{Array.from({ length }, (_, i) => (
<Cell
key={i}
char={code[i] ?? ""}
active={focused && i === activeIndex && code.length < length}
filled={Boolean(code[i])}
secure={secure}
status={status}
/>
))}
<Animated.View
style={[{ width: CELL_W, height: CELL_H }, ringStyle]}
className={cn(
"absolute left-0 top-0 rounded-xl border-2",
status === "error"
? "border-destructive"
: status === "success"
? "border-success"
: "border-ring"
)}
pointerEvents="none"
/>
{status === "success" ? <SuccessBadge /> : null}
</View>
</View>
</Animated.View>
<TextInput
ref={(node) => {
inputRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}}
value={code}
onChangeText={setCode}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
keyboardType="number-pad"
// iOS reads textContentType; "one-time-code" is the universal
// autoComplete token (covers Android SMS OTP too — "sms-otp" is the
// Android-only spelling).
textContentType="oneTimeCode"
autoComplete="one-time-code"
maxLength={length}
caretHidden
editable={!disabled}
// Transparent text/selection instead of opacity-0 — an invisible
// input lets iOS suppress the long-press Paste callout, while
// transparent styling keeps the full editing menu alive.
className="absolute inset-0"
style={{ color: "transparent", backgroundColor: "transparent" }}
selectionColor="transparent"
accessibilityLabel={`One-time code, ${length} digits`}
accessibilityValue={{ text: positionText }}
accessibilityState={{ disabled }}
{...props}
/>
{status === "error" && errorMessage ? (
<Text
className="mt-2 text-center text-sm text-destructive"
accessibilityLiveRegion="polite"
>
{errorMessage}
</Text>
) : null}
</View>
);
});
OtpInput.displayName = "OtpInput";
| value? | string | Controlled code value (digits/chars entered so far). | |
| onChange? | (value: string) => void | ||
| onComplete? | (code: string) => void | Fired once when the code reaches `length` characters. | |
| length? | number | Number of cells. Default 6. | |
| secure? | boolean | Mask cells like a password field. Default false. | |
| status? | OtpStatus | Validation state — error shakes the row, success tints the ring. Default "default". | |
| errorMessage? | string | Error message announced to screen readers when status is "error". | |
| disabled? | boolean | Disabled state — dims the row and blocks focus. Default false. | |
| className? | string | Extra classes for the outer wrapper. |
One-time-code input with N cells, auto-advance, backspace navigation, paste support, animated focus ring and per-cell haptics
import { OtpInput } from "@/components/ui/otp-input/otp-input";
export function Example() {
return (
<OtpInput />
);
} import * as React from "react";
import { forwardRef, useRef, useState } from "react";
import { Text, TextInput, View, type TextInputProps } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
withDelay,
withRepeat,
withSpring,
withTiming,
} from "react-native-reanimated";
import { cn, spring, timing, useHaptic, useReducedMotion, useTheme } from "@lumioui/core";
import { iconMap } from "@lumioui/ui";
export type OtpStatus = "default" | "error" | "success";
export interface OtpInputProps extends Omit<TextInputProps, "value" | "onChange" | "onChangeText" | "className"> {
/** Controlled code value (digits/chars entered so far). */
value?: string;
onChange?: (value: string) => void;
/** Fired once when the code reaches `length` characters. */
onComplete?: (code: string) => void;
/** Number of cells. Default 6. */
length?: number;
/** Mask cells like a password field. Default false. */
secure?: boolean;
/** Validation state — error shakes the row, success tints the ring. Default "default". */
status?: OtpStatus;
/** Error message announced to screen readers when status is "error". */
errorMessage?: string;
/** Disabled state — dims the row and blocks focus. Default false. */
disabled?: boolean;
/** Extra classes for the outer wrapper. */
className?: string;
}
/** Cell geometry — mirrors the w-12 / h-14 / gap-2 classes so the gliding
* focus ring can track cell positions with pure math (no measurement). */
const CELL_W = 48;
const CELL_H = 56;
const CELL_STEP = CELL_W + 8;
/** Error shake — a real damped oscillation: the row gets a 10pt kick and the
* shared spring.shake token rings it back to rest. More physical than
* keyframed segments, and short enough that the row reads shake, not wobble. */
const SHAKE_KICK = 10;
function shakeRow(translateX: { value: number }) {
translateX.value = SHAKE_KICK;
translateX.value = withSpring(0, spring.shake);
}
/** Caret blink — matches the iOS UITextField caret period (~530ms). */
const CARET_BLINK = { duration: 530 };
/** Success check draw — the badge pops first, then the check reveals
* left-to-right like a stroke being drawn (120ms lag, then a 250ms wipe). */
const CHECK_DRAW_DELAY = 120;
const CheckGlyph = iconMap.check;
interface CellProps {
char: string;
active: boolean;
filled: boolean;
secure: boolean;
status: OtpStatus;
}
function Cell({ char, active, filled, secure, status }: CellProps) {
const reduce = useReducedMotion();
const { colors } = useTheme();
/** Digit entrance driver — springs 0→1 with overshoot for the pop-in. */
const appear = useSharedValue(char ? 1 : 0);
/** Caret blink driver — oscillates 1↔0.15 while the active cell is empty. */
const caret = useSharedValue(0);
const prevChar = useRef(char);
React.useEffect(() => {
if (char !== prevChar.current) {
if (char) {
// Digit pop-in: scale up through a slight overshoot, quick fade.
// Also fires on digit replacement (autofill correcting a cell).
appear.value = 0.6;
appear.value = reduce ? 1 : withSpring(1, spring.bouncy);
} else {
appear.value = 0; // erase instantly — no exit theater mid-typing
}
prevChar.current = char;
}
}, [char, appear, reduce]);
React.useEffect(() => {
const showCaret = active && !char;
if (reduce) {
caret.value = showCaret ? 1 : 0;
} else if (showCaret) {
caret.value = 1;
caret.value = withRepeat(withTiming(0.15, CARET_BLINK), -1, true);
} else {
caret.value = 0;
}
}, [active, char, caret, reduce]);
const digitStyle = useAnimatedStyle(() => ({
opacity: Math.min(1, appear.value * 2),
// Springs overshoot past 1 — that carry-through is the pop.
transform: [{ scale: 0.55 + appear.value * 0.45 }],
}));
const caretStyle = useAnimatedStyle(() => ({ opacity: caret.value }));
return (
<View
className={cn(
"h-14 w-12 items-center justify-center rounded-xl border bg-card",
status === "error"
? "border-destructive"
: status === "success"
? "border-success"
: active
? "border-ring"
: filled
? "border-border"
: "border-input"
)}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Animated.View style={digitStyle}>
<Text className="text-xl font-semibold text-card-foreground">
{char ? (secure ? "•" : char) : ""}
</Text>
</Animated.View>
{/* Blinking caret in the focused empty cell — the "your digit lands
here" affordance iOS code fields have. */}
<Animated.View
style={[{ position: "absolute", width: 2, height: 22, borderRadius: 1, backgroundColor: colors.primary }, caretStyle]}
pointerEvents="none"
/>
</View>
);
}
/** Success badge — a success-tinted dot that pops onto the trailing cell,
* then draws its check via a left-to-right clip reveal (no SVG dep). */
function SuccessBadge() {
const reduce = useReducedMotion();
const { colors } = useTheme();
const pop = useSharedValue(0);
const draw = useSharedValue(0);
React.useEffect(() => {
if (reduce) {
pop.value = 1;
draw.value = 1;
return;
}
pop.value = withSpring(1, spring.bouncy);
draw.value = withDelay(CHECK_DRAW_DELAY, withTiming(1, timing.normal));
}, [pop, draw, reduce]);
const badgeStyle = useAnimatedStyle(() => ({
transform: [{ scale: pop.value }],
opacity: Math.min(1, pop.value * 2),
}));
const clipStyle = useAnimatedStyle(() => ({ width: draw.value * 12 }));
return (
<Animated.View
style={badgeStyle}
className="absolute -right-2 -top-2 h-5 w-5 items-center justify-center rounded-full bg-success"
pointerEvents="none"
>
<Animated.View style={[{ overflow: "hidden", width: 12 }, clipStyle]}>
{/* Fixed-width inner so the clip wipes the glyph instead of squashing it. */}
<View style={{ width: 12 }} className="items-center justify-center">
<CheckGlyph size={11} color={colors.successForeground} strokeWidth={3} fill="none" />
</View>
</Animated.View>
</Animated.View>
);
}
/**
* OtpInput — PRO. One-time-code input rendered as N cells over a hidden
* TextInput: auto-advance, backspace navigation and paste work natively.
* The focus ring glides between cells, digits pop in, an empty active cell
* shows a blinking caret, errors ring a damped shake, and completion pops a
* drawn-on check. Haptics: selection per char, error on failure, success
* on completion. Expo Go compatible.
*/
export const OtpInput = forwardRef<TextInput, OtpInputProps>(function OtpInput(
{
value,
onChange,
onComplete,
length = 6,
secure = false,
status = "default",
errorMessage,
disabled = false,
className,
...props
},
ref
) {
const haptics = useHaptic();
const reduce = useReducedMotion();
const inputRef = useRef<TextInput | null>(null);
const [focused, setFocused] = useState(false);
const [internal, setInternal] = useState("");
const code = value ?? internal;
const rowX = useSharedValue(0);
const ringX = useSharedValue(0);
const ringOn = useSharedValue(0);
const setCode = (next: string) => {
const trimmed = next.slice(0, length);
if (trimmed.length > code.length) haptics.selection();
if (value === undefined) setInternal(trimmed);
onChange?.(trimmed);
};
// Fire onComplete for typed AND controlled/autofilled completions.
const completedFor = useRef<string | null>(null);
React.useEffect(() => {
if (code.length === length && completedFor.current !== code) {
completedFor.current = code;
haptics.success();
onComplete?.(code);
}
if (code.length < length) completedFor.current = null;
}, [code, length, haptics, onComplete]);
// Error status shakes the row (unless reduced motion) and fires an
// error haptic so the failure is felt, not just seen.
const hadError = useRef(status === "error");
React.useEffect(() => {
if (status === "error" && !hadError.current) {
if (!reduce) shakeRow(rowX);
haptics.error();
}
hadError.current = status === "error";
}, [status, reduce, rowX, haptics]);
const activeIndex = Math.min(code.length, length - 1);
const ringVisible = focused && code.length < length;
// The ring glides cell-to-cell — spring.glide keeps the travel smooth and
// deliberate; the fade is quick so blur never leaves a stray outline.
React.useEffect(() => {
ringX.value = reduce ? activeIndex * CELL_STEP : withSpring(activeIndex * CELL_STEP, spring.glide);
ringOn.value = ringVisible ? withTiming(1, timing.fast) : withTiming(0, timing.fast);
}, [activeIndex, ringVisible, ringX, ringOn, reduce]);
const rowStyle = useAnimatedStyle(() => ({
transform: [{ translateX: rowX.value }],
}));
const ringStyle = useAnimatedStyle(() => ({
transform: [{ translateX: ringX.value }],
opacity: ringOn.value,
}));
const positionText = `${code.length} of ${length} digits entered`;
return (
<View className={cn(disabled && "opacity-60", className)}>
{/* Content-sized centered row by design — cells hug their size so the
group stays centered on narrow and wide screens alike. The inner
wrapper hugs the cells so the gliding ring can anchor to cell 0. */}
<Animated.View style={rowStyle}>
{/* No press-to-focus wrapper: the overlay TextInput below sits on
top of the cells and handles taps/focus itself. */}
<View className="flex-row justify-center" accessible={false}>
<View className="flex-row gap-2">
{Array.from({ length }, (_, i) => (
<Cell
key={i}
char={code[i] ?? ""}
active={focused && i === activeIndex && code.length < length}
filled={Boolean(code[i])}
secure={secure}
status={status}
/>
))}
<Animated.View
style={[{ width: CELL_W, height: CELL_H }, ringStyle]}
className={cn(
"absolute left-0 top-0 rounded-xl border-2",
status === "error"
? "border-destructive"
: status === "success"
? "border-success"
: "border-ring"
)}
pointerEvents="none"
/>
{status === "success" ? <SuccessBadge /> : null}
</View>
</View>
</Animated.View>
<TextInput
ref={(node) => {
inputRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}}
value={code}
onChangeText={setCode}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
keyboardType="number-pad"
// iOS reads textContentType; "one-time-code" is the universal
// autoComplete token (covers Android SMS OTP too — "sms-otp" is the
// Android-only spelling).
textContentType="oneTimeCode"
autoComplete="one-time-code"
maxLength={length}
caretHidden
editable={!disabled}
// Transparent text/selection instead of opacity-0 — an invisible
// input lets iOS suppress the long-press Paste callout, while
// transparent styling keeps the full editing menu alive.
className="absolute inset-0"
style={{ color: "transparent", backgroundColor: "transparent" }}
selectionColor="transparent"
accessibilityLabel={`One-time code, ${length} digits`}
accessibilityValue={{ text: positionText }}
accessibilityState={{ disabled }}
{...props}
/>
{status === "error" && errorMessage ? (
<Text
className="mt-2 text-center text-sm text-destructive"
accessibilityLiveRegion="polite"
>
{errorMessage}
</Text>
) : null}
</View>
);
});
OtpInput.displayName = "OtpInput";