/** * OtpInput — a one-time-code field drawn as a row of separate cells. * * The problem this solves is that a code is read and typed one character at a * time, and a single text box hides that: you cannot see, at a glance, how * many digits are left. So the value is spread across `length` cells, each its * own box, and the caret sits in the cell you are about to fill. * * There is still exactly one text field underneath — a transparent input laid * over the whole row — because the software keyboard, autofill and paste all * belong to a real `TextInput`, and splitting the value across N inputs fights * every one of them. The cells are drawn from the value; the input owns it. * Tapping any cell focuses that one input, so the platform never sees the seam. * * The active cell's border crosses to the focus colour on the UI thread rather * than snapping, matching the rest of the field family, and a caret blinks in * the cell awaiting input. Each digit zooms in as it lands, so a fast type or a * one-tap autofill reads as the code arriving rather than the box redrawing. * * ```tsx * const [code, setCode] = useState(''); * * * ``` */ import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Pressable, TextInput, View, type TextInputProps } from 'react-native'; import Animated, { cancelAnimation, interpolateColor, useAnimatedStyle, useSharedValue, withRepeat, withSequence, withTiming, ZoomIn, } from 'react-native-reanimated'; import { tv, type VariantProps } from 'tailwind-variants'; import { useCSSVariable } from 'uniwind'; import { Text } from '../../primitives/text'; import { resolveOtpEditable } from './otp-input-state'; /** Long enough to read as a transition, short enough not to lag a fast type. */ const FOCUS_DURATION = 150; /** A caret that is on for the same time it is off reads as a resting cursor. */ const CARET_PERIOD = 560; const otpVariants = tv({ slots: { container: 'gap-1.5', row: 'flex-row items-center', cell: 'items-center justify-center rounded-lg border border-input bg-background', /* The typed character is drawn by an Animated.Text rather than the Text primitive, so its weight class has to be written here for an app's --font-normal token to reach it. */ char: 'font-normal text-foreground', caret: 'w-0.5 rounded-full bg-foreground', separator: 'h-0.5 rounded-full bg-border', error: 'text-sm text-destructive', }, variants: { size: { sm: { cell: 'h-10 w-9', char: 'text-base', caret: 'h-5', separator: 'w-2' }, md: { cell: 'h-12 w-11', char: 'text-lg', caret: 'h-6', separator: 'w-2.5' }, lg: { cell: 'h-14 w-12', char: 'text-xl', caret: 'h-7', separator: 'w-3' }, }, disabled: { true: { row: 'opacity-[0.64]' }, }, }, defaultVariants: { size: 'md', }, }); type OtpVariantProps = VariantProps; export interface OtpInputProps extends Omit< TextInputProps, 'value' | 'defaultValue' | 'onChangeText' | 'maxLength' | 'children' >, Omit { className?: string; /** Class applied to every cell — for a taller box or a different radius. */ cellClassName?: string; /** How many cells, i.e. the length of the code. */ length?: number; /** Controlled value. Longer strings and stray characters are trimmed to fit. */ value?: string; /** Starting value when the field manages its own state. */ defaultValue?: string; onChangeText?: (value: string) => void; /** Fires once, the moment the last cell is filled. */ onComplete?: (value: string) => void; /** * What the keyboard offers and what the field accepts. `numeric` keeps * digits only and asks for the number pad; `text` accepts any character. */ type?: 'numeric' | 'text'; /** Hide each filled character behind a dot, the way a passcode field does. */ mask?: boolean; /** A single character shown, dimmed, in every cell still waiting for input. */ placeholder?: string; /** Draw a separator between groups of this many cells — 3 gives `xxx — xxx`. */ groupEvery?: number; disabled?: boolean; /** Tint the field in its error colour and announce it as invalid. */ isInvalid?: boolean; /** Error line under the field. Setting it also puts the field in its invalid state. */ errorMessage?: string; /** Announced by a screen reader as the field's name. */ accessibilityLabel?: string; } interface CellProps { char: string; active: boolean; focused: boolean; mask: boolean; placeholder?: string; invalid: boolean; className: string; charClassName: string; caretClassName: string; } /** * One box. It owns three animations, all on the UI thread: the border crossing * to the focus (or error) colour when it becomes the active cell, the caret * blinking while it waits, and the entrance of the character that lands in it. */ function OtpCell({ char, active, focused, mask, placeholder, invalid, className, charClassName, caretClassName, }: CellProps) { const restColor = useCSSVariable('--color-input'); const focusColor = useCSSVariable('--color-ring'); const errorColor = useCSSVariable('--color-destructive'); const placeholderColor = useCSSVariable('--color-muted-foreground'); const resting = typeof restColor === 'string' ? restColor : '#e5e5e5'; const activeColor = invalid ? typeof errorColor === 'string' ? errorColor : '#ef4444' : typeof focusColor === 'string' ? focusColor : '#a3a3a3'; // An invalid field carries its tint even when it is not the active cell — // the error is a fact about the value, not about where the caret happens to be. const idle = invalid ? activeColor : resting; const lit = active && focused; const border = useSharedValue(lit ? 1 : 0); useEffect(() => { border.value = withTiming(lit ? 1 : 0, { duration: FOCUS_DURATION }); }, [lit, border]); const borderStyle = useAnimatedStyle(() => ({ borderColor: interpolateColor(border.value, [0, 1], [idle, activeColor]), // A ring only when lit — the resting cells keep their hairline. borderWidth: 1 + border.value, })); const caret = useSharedValue(0); useEffect(() => { if (lit && char.length === 0) { caret.value = withRepeat( withSequence( withTiming(1, { duration: 1 }), withTiming(1, { duration: CARET_PERIOD }), withTiming(0, { duration: CARET_PERIOD }) ), -1, false ); } else { cancelAnimation(caret); caret.value = 0; } return () => cancelAnimation(caret); }, [lit, char.length, caret]); const caretStyle = useAnimatedStyle(() => ({ opacity: caret.value })); return ( {char.length > 0 ? ( {mask ? '•' : char} ) : lit ? ( ) : placeholder ? ( {placeholder} ) : null} ); } /** Clamp and filter a raw string down to what this field will hold. */ function sanitize(raw: string, length: number, type: 'numeric' | 'text'): string { const filtered = type === 'numeric' ? raw.replace(/[^0-9]/g, '') : raw; return filtered.slice(0, length); } export const OtpInput = forwardRef( ( { className, cellClassName, length = 6, value: valueProp, defaultValue = '', onChangeText, onComplete, type = 'numeric', mask = false, placeholder, groupEvery = 0, size, disabled, editable, isInvalid, errorMessage, accessibilityLabel, onFocus, onBlur, style, ...props }, ref ) => { const controlled = valueProp != null; const [internal, setInternal] = useState(() => sanitize(defaultValue, length, type) ); const value = sanitize(controlled ? valueProp! : internal, length, type); const invalid = isInvalid || !!errorMessage; const [focused, setFocused] = useState(false); const inputRef = useRef(null); // A one-shot guard so onComplete fires on the transition to full, not on // every keystroke that leaves the field full (e.g. a trailing focus event). const completedRef = useRef(false); const slots = otpVariants({ size, disabled: !!disabled }); const setRef = useCallback( (node: TextInput | null) => { inputRef.current = node; if (typeof ref === 'function') ref(node); else if (ref) ref.current = node; }, [ref] ); const handleChange = useCallback( (raw: string) => { const next = sanitize(raw, length, type); if (!controlled) setInternal(next); onChangeText?.(next); if (next.length === length) { if (!completedRef.current) { completedRef.current = true; onComplete?.(next); } } else { completedRef.current = false; } }, [controlled, length, type, onChangeText, onComplete] ); // Keep the guard honest when the value is driven from outside (a reset, a // paste through the controlled prop) rather than through the keyboard. useEffect(() => { completedRef.current = value.length === length; }, [value.length, length]); const focus = useCallback(() => { if (!disabled) inputRef.current?.focus(); }, [disabled]); const activeIndex = Math.min(value.length, length - 1); const cells = useMemo(() => Array.from({ length }, (_, i) => i), [length]); return ( {/* The row is one big hit target; the field underneath is the a11y node. */} {cells.map((i) => ( 0 ? 'ms-2' : ''} ${cellClassName ?? ''}`, })} charClassName={slots.char()} caretClassName={slots.caret()} /> {groupEvery > 0 && (i + 1) % groupEvery === 0 && i < length - 1 ? ( ) : null} ))} {/* * The real field: one transparent input stretched over the whole row. * It carries the keyboard, autofill and paste, and its value is the * source the cells are drawn from — there is no per-cell input to keep * in sync. `pointerEvents` off so taps fall through to the Pressable, * which focuses this exact input. */} { setFocused(true); onFocus?.(e); }} onBlur={(e) => { setFocused(false); onBlur?.(e); }} style={[ { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, opacity: 0, // Taps fall through to the Pressable, which focuses this input — // so the caret lands wherever the row is touched. pointerEvents: 'none', }, style, ]} {...props} /> {errorMessage ? ( {errorMessage} ) : null} ); } ); OtpInput.displayName = 'OtpInput';