text input
Secure Code Flow
A native OTP and PIN flow with paste, autofill, masking, and animated validation states.
iOSAndroidExpo Go
reanimatedworklets
Installation
Install the dependencies, then copy the files for your preferred renderer.
pnpm add react-native-reanimated react-native-workletsControlled and uncontrolled values
Use value with onValueChange when the verification state belongs to the
parent. Use defaultValue when the component may own the entered code.
onComplete fires only when the value crosses from fewer than length
characters to exactly length; validation rerenders do not call it again.
Install tailwind variants
pnpm add tailwind-variantsUsage
SecureCodeFlowExample.tsximport { SecureCodeFlow } from "@animations/ui/components/text-input/secure-code-flow/uniwind";export function SecureCodeFlowExample() { return ( <SecureCodeFlow classNames={{ cell: "rounded-xl" }} defaultValue="" length={6} mask onComplete={(pin) => console.log({ pin })} onValueChange={(code) => console.log({ code })} /> );}secure-code-flow/cell.tsximport type { PropsWithChildren } from "react";import type { StyleProp, ViewStyle } from "react-native";import Animated, { useAnimatedStyle, withDelay, withSequence, withTiming,} from "react-native-reanimated";type SecureCodeCellProps = PropsWithChildren<{ active: boolean; className?: string; error: boolean; filled: boolean; revealDelay?: number; style?: StyleProp<ViewStyle>;}>;export function SecureCodeCell({ active, children, className, error, filled, revealDelay = 0, style,}: SecureCodeCellProps) { const cellAnimatedStyle = useAnimatedStyle(() => ({ transform: [ { scale: withTiming(active ? 1.045 : 1, { duration: 140, }), }, { translateX: error ? withSequence( withTiming(-3, { duration: 45 }), withTiming(3, { duration: 70 }), withTiming(0, { duration: 45 }), ) : 0, }, ], })); const contentAnimatedStyle = useAnimatedStyle(() => ({ opacity: withDelay( revealDelay, withTiming(filled ? 1 : 0, { duration: 120 }), ), transform: [ { scale: withDelay( revealDelay, withTiming(filled ? 1 : 0.72, { duration: 150 }), ), }, { translateY: withDelay( revealDelay, withTiming(filled ? 0 : 5, { duration: 150 }), ), }, ], })); return ( <Animated.View className={className} style={[style, cellAnimatedStyle]} > <Animated.View style={contentAnimatedStyle}> {children} </Animated.View> </Animated.View> );}secure-code-flow/config.tsexport const SECURE_CODE_FLOW_CONFIG = { cellSize: 46, defaultLength: 6,} as const;secure-code-flow/types.tsexport type SecureCodeFlowStatus = | "error" | "idle" | "success" | "validating";export type SecureCodeFlowBaseProps = { accessibilityLabel?: string; autoFocus?: boolean; defaultValue?: string; disabled?: boolean; length?: number; mask?: boolean; onComplete?: (code: string) => void; onValueChange?: (code: string) => void; status?: SecureCodeFlowStatus; value?: string;};secure-code-flow/uniwind/index.tsximport { Pressable, Text, TextInput, View } from "react-native";import type { SlotsToClasses } from "#shared/types/slots";import { SecureCodeCell } from "../cell";import { SECURE_CODE_FLOW_CONFIG } from "../config";import type { SecureCodeFlowBaseProps } from "../types";import { useSecureCodeFlow } from "../use-secure-code-flow";import { secureCodeFlowVariants, type SecureCodeFlowSlot,} from "./variants";export type SecureCodeFlowProps = SecureCodeFlowBaseProps & { className?: string; classNames?: SlotsToClasses<SecureCodeFlowSlot>;};export function SecureCodeFlow({ accessibilityLabel = "Verification code", autoFocus, className, classNames, defaultValue, disabled = false, length = SECURE_CODE_FLOW_CONFIG.defaultLength, mask = false, onComplete, onValueChange, status = "idle", value,}: SecureCodeFlowProps) { const { blur, changeValue, code, focus, focused, inputRef, setFocused, } = useSecureCodeFlow({ defaultValue, disabled, length, onComplete, onValueChange, value, }); const { input, root, row } = secureCodeFlowVariants({ disabled, }); return ( <Pressable className={root({ className: [className, classNames?.root], })} disabled={disabled} onPress={focus} > <View className={row({ className: classNames?.row })}> {Array.from({ length }, (_, index) => { const digit = code[index]; const active = focused && index === Math.min(code.length, length - 1); const { cell, cellText } = secureCodeFlowVariants({ active, status, }); return ( <SecureCodeCell key={index} active={active} className={cell({ className: classNames?.cell, })} error={status === "error"} filled={Boolean(digit)} revealDelay={index * 24} > <Text className={cellText({ className: classNames?.cellText, })} > {digit ? (mask ? "•" : digit) : ""} </Text> </SecureCodeCell> ); })} </View> <TextInput accessibilityLabel={accessibilityLabel} autoComplete="one-time-code" autoFocus={autoFocus} caretHidden className={input({ className: classNames?.input })} editable={!disabled && status !== "validating"} inputMode="numeric" maxLength={length} onBlur={blur} onChangeText={changeValue} onFocus={() => { setFocused(true); }} ref={inputRef} textContentType="oneTimeCode" value={code} /> </Pressable> );}secure-code-flow/uniwind/variants.tsimport { tv } from "tailwind-variants";export const secureCodeFlowSlots = { cell: "h-13.5 w-11.5 items-center justify-center rounded-[15px] border-[1.5px] border-[#deded6] bg-white", cellText: "text-[23px] font-black text-[#171713]", input: "absolute inset-0 opacity-0", root: "relative w-full", row: "flex-row justify-center gap-1.75",} as const;export type SecureCodeFlowSlot = keyof typeof secureCodeFlowSlots;export const secureCodeFlowVariants = tv({ slots: secureCodeFlowSlots, variants: { active: { true: { cell: "border-[#725cff]", }, }, disabled: { true: { root: "opacity-[0.45]", }, }, status: { error: { cell: "border-[#f05a47] bg-[#fff1ee]", }, idle: {}, success: {}, validating: {}, }, },});secure-code-flow/use-secure-code-flow.tsimport { useCallback, useRef, useState } from "react";import type { TextInput } from "react-native";import { SECURE_CODE_FLOW_CONFIG } from "./config";import type { SecureCodeFlowBaseProps } from "./types";export function useSecureCodeFlow({ defaultValue = "", disabled = false, length = SECURE_CODE_FLOW_CONFIG.defaultLength, onComplete, onValueChange, value,}: Pick< SecureCodeFlowBaseProps, | "defaultValue" | "disabled" | "length" | "onComplete" | "onValueChange" | "value">) { if (!Number.isInteger(length) || length < 1) { throw new Error( "SecureCodeFlow requires length to be a positive integer.", ); } const inputRef = useRef<TextInput>(null); const isControlled = value !== undefined; const [internalValue, setInternalValue] = useState(() => defaultValue.replace(/\D/g, "").slice(0, length), ); const [focused, setFocused] = useState(false); const code = (value ?? internalValue) .replace(/\D/g, "") .slice(0, length); const changeValue = useCallback( (nextText: string) => { const nextCode = nextText .replace(/\D/g, "") .slice(0, length); if (!isControlled) { setInternalValue(nextCode); } onValueChange?.(nextCode); if (nextCode.length === length && code.length !== length) { onComplete?.(nextCode); } }, [code.length, isControlled, length, onComplete, onValueChange], ); return { blur: () => { setFocused(false); }, changeValue, code, focus: () => { if (!disabled) { inputRef.current?.focus(); } }, focused, inputRef, setFocused, };}