Veltrix

text input

Secure Code Flow

A native OTP and PIN flow with paste, autofill, masking, and animated validation states.

AppleiOSAndroidAndroidExpoExpo Go
reanimatedworklets
GitHubOpen in GitHub

Installation

Install the dependencies, then copy the files for your preferred renderer.

pnpm add react-native-reanimated react-native-worklets

Controlled 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-variants

Usage

SecureCodeFlowExample.tsx
import { 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 })}    />  );}

Component files

6 files

secure-code-flow/cell.tsx
import 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.ts
export const SECURE_CODE_FLOW_CONFIG = {  cellSize: 46,  defaultLength: 6,} as const;
secure-code-flow/types.ts
export 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.tsx
import { 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.ts
import { 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.ts
import { 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,  };}

API reference

Generated directly from the exported component props.

SecureCodeFlow