v0.1 public beta early access on GitHub
Home / Screens / auth-otp-verification
Screen template

OTP Verification Screen

Six-digit code entry with auto-advance cells, backspace navigation, resend countdown, and auto-submit

pro Expo Go ✓ iosandroid gestures: tap haptics: selection · light · medium · error
$ npx lumioui add auth-otp-verification
import { OTPVerificationScreen } from "@/screens/auth/otp-verification";
Registry dependencies4
Native packages installed6
@lumioui/ui@latest@lumioui/core@latestreact-native-safe-area-context@*react-native-reanimated@*react-native-svg@*nativewind@*
auth/otp-verification.tsxsource · yours to edit
import * as React from "react";
import { View, TextInput, ScrollView, Pressable } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useState, useRef, useEffect } from "react";
import { interpolateColor, useAnimatedStyle, useSharedValue, withSpring, withTiming, } from "react-native-reanimated";
import { Button, Icon, Text, KeyboardAvoiding } from "@lumioui/ui";
import { AnimatedTextInput, cn, spring, timing, useHaptic, useReducedMotion, useTheme, } from "@lumioui/core";
import { Mount, Press, BLOCK_STAGGER, World } from "../shared";

export interface OTPVerificationScreenProps {
  length?: number;
  email?: string;
  /** Server-side rejection message (e.g. "That code expired"). */
  error?: string;
  onVerify?: (code: string) => void;
  onResend?: () => void;
  onBack?: () => void;
  /** Seconds before resend is allowed. */
  resendCooldown?: number;
  /** Prefilled code for demos/previews — production hosts omit it. */
  defaultCode?: string;
}

/** OTP verification — oversized cells with a gliding focus ring and digit pop,
 *  paste-to-fill, countdown resend, auto-submit. */
export function OTPVerificationScreen({
  length = 6,
  email,
  error,
  onVerify,
  onResend,
  onBack,
  resendCooldown = 30,
  defaultCode,
}: OTPVerificationScreenProps) {
  const haptics = useHaptic();
  const insets = useSafeAreaInsets();
  const [digits, setDigits] = useState<string[]>(() => {
    const arr = Array<string>(length).fill("");
    if (defaultCode) {
      for (let i = 0; i < Math.min(length, defaultCode.length); i++) arr[i] = defaultCode.charAt(i);
    }
    return arr;
  });
  const [focused, setFocused] = useState<number | null>(null);
  const [loading, setLoading] = useState(false);
  const [cooldown, setCooldown] = useState(resendCooldown);
  const refs = useRef<(TextInput | null)[]>([]);

  useEffect(() => {
    if (cooldown <= 0) return;
    const t = setInterval(() => setCooldown((c) => c - 1), 1000);
    return () => clearInterval(t);
  }, [cooldown]);

  // Surface a haptic rejection when the server-side error arrives.
  useEffect(() => {
    if (error) haptics.error();
  }, [error, haptics]);

  const complete = (next: string[]) => {
    if (next.every((d) => d) && !loading) {
      setLoading(true);
      onVerify?.(next.join(""));
      setTimeout(() => setLoading(false), 800);
    }
  };

  const setDigit = (i: number, v: string) => {
    const chars = v.replace(/\D/g, "");
    if (!chars) {
      // Deletion — clear this cell; backspace-to-previous is handled in onKey.
      const next = [...digits];
      next[i] = "";
      setDigits(next);
      return;
    }
    const next = [...digits];
    if (chars.length > 1) {
      // Paste / autofill — distribute the digits across cells from here on.
      for (let k = 0; k < chars.length && i + k < length; k++) {
        next[i + k] = chars[k] ?? "";
      }
      const target = Math.min(i + chars.length, length - 1);
      refs.current[target]?.focus();
    } else {
      next[i] = chars.slice(-1);
      if (i < length - 1) refs.current[i + 1]?.focus();
    }
    setDigits(next);
    haptics.selection();
    complete(next);
  };

  const onKey = (i: number, key: string) => {
    if (key === "Backspace" && !digits[i] && i > 0) {
      refs.current[i - 1]?.focus();
      const next = [...digits];
      next[i - 1] = "";
      setDigits(next);
    }
  };

  return (
    <World primary="#65A30D">
    <View className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]">
      {/* Top bar — back affordance clears the status bar */}
      <View className="px-2" style={{ paddingTop: insets.top + 4 }}>
        <Press
          onPress={onBack}
          haptic="light"
          pressedScale={0.97}
          accessibilityRole="button"
          accessibilityLabel="Go back"
          hitSlop={8}
          className="h-11 flex-row items-center gap-1 self-start px-2"
        >
          <Icon name="chevron-left" size="sm" color="#65A30D" />
          <Text className="text-base text-[#65A30D]">Back</Text>
        </Press>
      </View>

      <KeyboardAvoiding>
        <ScrollView
          showsVerticalScrollIndicator={false}
          keyboardShouldPersistTaps="handled"
          keyboardDismissMode="interactive"
          contentContainerClassName="flex-grow justify-center px-6"
          contentContainerStyle={{ paddingBottom: insets.bottom + 24 }}
        >
          <View className="gap-9">
            {/* Header — tinted icon tile + display title */}
            <Mount delay={0}>
              <View className="gap-3">
                {/* Message-in-a-ring — a delivered-mail motif, not a
                    generic security tile. */}
                <View className="h-16 w-16 items-center justify-center rounded-full border-2 border-[#65A30D]/30 bg-[#65A30D]/10">
                  <View className="h-11 w-11 items-center justify-center rounded-full bg-[#65A30D]">
                    <Icon name="mail" size="sm" color="#FFFFFF" />
                  </View>
                </View>
                <Text variant="h1" className="mt-3 tracking-tight">
                  Enter verification code
                </Text>
                <Text variant="muted" className="max-w-xs text-base leading-relaxed">
                  {email ? (
                    <>
                      We sent a {length}-digit code to{" "}
                      <Text className="font-semibold text-foreground">{email}</Text>
                      {". "}
                      <Text
                        className="text-[#65A30D] font-medium"
                        accessibilityRole="link"
                        onPress={() => {
                          haptics.light();
                          onBack?.();
                        }}
                      >
                        Edit
                      </Text>
                    </>
                  ) : (
                    `Enter the ${length}-digit code we sent you.`
                  )}
                </Text>
              </View>
            </Mount>

            {/* iOS autofill hint — the "From Messages" suggestion bar the
                keyboard shows. Tapping it fills the code (the same code the
                phone-verification preview showed). */}
            <Mount delay={BLOCK_STAGGER / 2}>
              <Pressable
                onPress={() => {
                  haptics.selection();
                  "481923".split("").slice(0, length).forEach((d, i) => setDigit(i, d));
                }}
                accessibilityRole="button"
                accessibilityLabel="Autofill code 481923 from messages"
                hitSlop={8}
                className="mb-4 flex-row items-center gap-2 self-center overflow-hidden rounded-full bg-white px-4 py-2 dark:bg-[#1A1A20]"
              >
                <Icon name="message-circle" size="sm" color="mutedForeground" />
                <Text variant="small" className="text-muted-foreground">
                  From Messages:{" "}
                  <Text className="font-semibold tracking-widest text-foreground">481 923</Text>
                </Text>
              </Pressable>
            </Mount>

            {/* Cells — flex-1 so the row fills any width evenly */}
            <Mount delay={BLOCK_STAGGER}>
              <View>
                <View
                  className="flex-row gap-2"
                  accessibilityRole="adjustable"
                  accessibilityLabel={`Verification code, ${digits.filter(Boolean).length} of ${length} digits entered`}
                >
                  {digits.map((d, i) => (
                    <OtpCell
                      key={i}
                      index={i}
                      value={d}
                      focused={i === focused}
                      hasError={!!error}
                      length={length}
                      inputRef={(r) => {
                        refs.current[i] = r;
                      }}
                      onChange={setDigit}
                      onKey={onKey}
                      onFocusCell={setFocused}
                    />
                  ))}
                </View>
                {error ? (
                  <Text
                    variant="small"
                    className="mt-3 text-destructive"
                    accessibilityLiveRegion="assertive"
                    accessibilityRole="alert"
                  >
                    {error}
                  </Text>
                ) : null}
              </View>
            </Mount>

            <Mount delay={BLOCK_STAGGER * 2}>
              <Button
                size="lg"
                loading={loading}
                disabled={!digits.every(Boolean)}
                onPress={() => onVerify?.(digits.join(""))}
                haptic="medium"
                className="bg-[#65A30D] w-full"
              >
                Verify
              </Button>
            </Mount>

            {/* Resend — countdown then a real link */}
            <Mount delay={BLOCK_STAGGER * 3}>
              <View className="items-center">
                {cooldown > 0 ? (
                  <Text variant="muted" style={{ fontVariant: ["tabular-nums"] }}>
                    Resend code in {cooldown}s
                  </Text>
                ) : (
                  <Press
                    onPress={() => {
                      setCooldown(resendCooldown);
                      onResend?.();
                    }}
                    haptic="light"
                    pressedScale={0.97}
                    accessibilityRole="button"
                    accessibilityLabel="Resend verification code"
                    hitSlop={8}
                    className="min-h-11 flex-row items-center justify-center gap-2 px-2"
                  >
                    <Icon name="refresh" size="sm" color="#65A30D" />
                    <Text className="text-[#65A30D] font-semibold" style={{ fontVariant: ["tabular-nums"] }}>
                      Resend code
                    </Text>
                  </Press>
                )}
              </View>
            </Mount>
          </View>
        </ScrollView>
      </KeyboardAvoiding>
    </View>
    </World>
  );
}

interface OtpCellProps {
  index: number;
  value: string;
  focused: boolean;
  hasError: boolean;
  length: number;
  inputRef: (r: TextInput | null) => void;
  onChange: (index: number, value: string) => void;
  onKey: (index: number, key: string) => void;
  onFocusCell: (index: number | null) => void;
}

/** One OTP digit — the border color glides input → ring on focus (same
 *  interpolateColor idiom as Input), the cell lifts slightly while focused,
 *  and a digit arrival pops the scale before settling. */
function OtpCell({ index, value, focused, hasError, length, inputRef, onChange, onKey, onFocusCell }: OtpCellProps) {
  const { colors } = useTheme();
  const reduce = useReducedMotion();
  /** Focus progression — drives the border glide and the lift. */
  const focusT = useSharedValue(focused ? 1 : 0);
  /** Scale driver — filled digits overshoot once, then settle to focus scale. */
  const scale = useSharedValue(1);
  const wasFilled = useRef(!!value);

  useEffect(() => {
    const filled = !!value;
    if (filled && !wasFilled.current && !reduce) scale.value = 1.1;
    wasFilled.current = filled;
    const target = focused ? 1.05 : 1;
    scale.value = reduce ? target : withSpring(target, spring.press);
  }, [value, focused, reduce, scale]);

  useEffect(() => {
    const target = focused ? 1 : 0;
    focusT.value = reduce ? target : withTiming(target, timing.fast);
  }, [focused, reduce, focusT]);

  const cellStyle = useAnimatedStyle(() => ({
    borderColor: hasError
      ? colors.destructive
      : interpolateColor(
          focusT.value,
          [0, 1],
          // Tonal at rest — the border matches the cell surface, so only
          // the focus ring glides in; no resting hairline.
          [value ? colors.muted : colors.card, colors.ring]
        ),
    transform: [{ scale: scale.value }],
  }));

  return (
    <View className="min-w-0 flex-1">
      <View className="absolute inset-0 rounded-2xl bg-white dark:bg-[#1A1A20]" pointerEvents="none" />
      <AnimatedTextInput
      ref={inputRef}
      value={value}
      onChangeText={(v) => onChange(index, v)}
      onKeyPress={({ nativeEvent }) => onKey(index, nativeEvent.key)}
      onFocus={() => onFocusCell(index)}
      onBlur={() => onFocusCell(null)}
      keyboardType="number-pad"
      textContentType="oneTimeCode"
      autoComplete="sms-otp"
      returnKeyType="done"
      // autoFocus removed to prevent auto-scrolling embedded previews
      selectTextOnFocus
      // Tabular figures keep each cell from jittering as digits change.
      style={[{ fontVariant: ["tabular-nums"] }, cellStyle]}
      className={cn(
        // min-w-0: web inputs carry an intrinsic size=20 min-width that
        // flex-1 can't shrink below — six cells would overflow the row.
        "h-16 w-full rounded-2xl border-[1.5px] border-transparent bg-transparent text-center text-2xl font-bold text-foreground",
        hasError && "bg-destructive/10"
      )}
      accessibilityLabel={`Digit ${index + 1} of ${length}`}
    />
    </View>
  );
}
OtpCell.displayName = "OtpCell";
agent prompt — paste into Claude Code, Cursor, Devin…
Recreate the "OTP Verification Screen" screen in React Native using LumioUI.

Goal: Six-digit code entry with auto-advance cells, backspace navigation, resend countdown, and auto-submit Match the reference implementation's layout, behavior, and motion exactly — this is a production screen, not a mockup.

## Setup

Fastest path — install the screen plus its component deps:

```bash
npx lumioui add auth-otp-verification
```

Or pull just the components: `npx lumioui add button icon keyboard-avoiding text`

Native packages:
- @lumioui/ui@latest
- @lumioui/core@latest
- react-native-safe-area-context@*
- react-native-reanimated@*
- react-native-svg@*
- nativewind@*

Stack assumptions: Expo SDK 54 / RN 0.81, Reanimated 4, NativeWind v4, TypeScript strict, Expo Go compatible.

## Components — how, where, when

### `button` — Tactile pill button with press spring, ink wash and async loading.
- `<Button>` ×1

### `icon` — Lucide icon set mapped to theme colors and sizes.
- `<Icon>` ×4

### `keyboard-avoiding` — View that lifts above the keyboard on both platforms.
- `<KeyboardAvoiding>` ×1

### `text` — Themed type scale from caption to display.
- `<Text>` ×10

## Conventions (non-negotiable)

- Styling via NativeWind `className` with semantic tokens only: `bg-background`, `text-foreground`, `bg-card`, `text-card-foreground`, `bg-secondary`, `bg-muted`, `text-muted-foreground`, `bg-accent`, `bg-destructive`, `border-border`. Hard-coded colors only inside media overlays (`text-white`, `bg-black/N`).
- Motion via the token presets from `@lumioui/core` (`spring.press`, `spring.surface`, `spring.glide`, `timing.fast`, `timing.slow`, …) — no bespoke spring configs or durations.
- Gate transform motion behind `useReducedMotion()`; keep opacity/color feedback, drop travel and overshoot.
- Haptics via `useHaptic()`: `light` on press, `selection` on toggle/select, `warning` on invalid input. Never import expo-haptics directly.
- Press feedback via `usePressAnimation()` (~0.97 scale), not `activeOpacity` tricks.
- a11y: `accessibilityRole` + `accessibilityLabel` on every control, touch targets ≥44pt.
- Entrance choreography: staggered `Mount` wrappers (delay 0/60/120…) so regions land in sequence instead of popping in at once.
- Wrap the screen in `SafeAreaView`; keep dark mode working via the semantic tokens.

## Deliverable

One file: `screens/auth/otp-verification.tsx` exporting OTPVerificationScreen. Preserve the props interface — the host app wires the callbacks (navigation, submit handlers) through them.

## Reference implementation

The LumioUI source for this screen — reproduce it faithfully:

```tsx
import * as React from "react";
import { View, TextInput, ScrollView, Pressable } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useState, useRef, useEffect } from "react";
import { interpolateColor, useAnimatedStyle, useSharedValue, withSpring, withTiming, } from "react-native-reanimated";
import { Button, Icon, Text, KeyboardAvoiding } from "@lumioui/ui";
import { AnimatedTextInput, cn, spring, timing, useHaptic, useReducedMotion, useTheme, } from "@lumioui/core";
import { Mount, Press, BLOCK_STAGGER, World } from "../shared";

export interface OTPVerificationScreenProps {
  length?: number;
  email?: string;
  /** Server-side rejection message (e.g. "That code expired"). */
  error?: string;
  onVerify?: (code: string) => void;
  onResend?: () => void;
  onBack?: () => void;
  /** Seconds before resend is allowed. */
  resendCooldown?: number;
  /** Prefilled code for demos/previews — production hosts omit it. */
  defaultCode?: string;
}

/** OTP verification — oversized cells with a gliding focus ring and digit pop,
 *  paste-to-fill, countdown resend, auto-submit. */
export function OTPVerificationScreen({
  length = 6,
  email,
  error,
  onVerify,
  onResend,
  onBack,
  resendCooldown = 30,
  defaultCode,
}: OTPVerificationScreenProps) {
  const haptics = useHaptic();
  const insets = useSafeAreaInsets();
  const [digits, setDigits] = useState<string[]>(() => {
    const arr = Array<string>(length).fill("");
    if (defaultCode) {
      for (let i = 0; i < Math.min(length, defaultCode.length); i++) arr[i] = defaultCode.charAt(i);
    }
    return arr;
  });
  const [focused, setFocused] = useState<number | null>(null);
  const [loading, setLoading] = useState(false);
  const [cooldown, setCooldown] = useState(resendCooldown);
  const refs = useRef<(TextInput | null)[]>([]);

  useEffect(() => {
    if (cooldown <= 0) return;
    const t = setInterval(() => setCooldown((c) => c - 1), 1000);
    return () => clearInterval(t);
  }, [cooldown]);

  // Surface a haptic rejection when the server-side error arrives.
  useEffect(() => {
    if (error) haptics.error();
  }, [error, haptics]);

  const complete = (next: string[]) => {
    if (next.every((d) => d) && !loading) {
      setLoading(true);
      onVerify?.(next.join(""));
      setTimeout(() => setLoading(false), 800);
    }
  };

  const setDigit = (i: number, v: string) => {
    const chars = v.replace(/\D/g, "");
    if (!chars) {
      // Deletion — clear this cell; backspace-to-previous is handled in onKey.
      const next = [...digits];
      next[i] = "";
      setDigits(next);
      return;
    }
    const next = [...digits];
    if (chars.length > 1) {
      // Paste / autofill — distribute the digits across cells from here on.
      for (let k = 0; k < chars.length && i + k < length; k++) {
        next[i + k] = chars[k] ?? "";
      }
      const target = Math.min(i + chars.length, length - 1);
      refs.current[target]?.focus();
    } else {
      next[i] = chars.slice(-1);
      if (i < length - 1) refs.current[i + 1]?.focus();
    }
    setDigits(next);
    haptics.selection();
    complete(next);
  };

  const onKey = (i: number, key: string) => {
    if (key === "Backspace" && !digits[i] && i > 0) {
      refs.current[i - 1]?.focus();
      const next = [...digits];
      next[i - 1] = "";
      setDigits(next);
    }
  };

  return (
    <World primary="#65A30D">
    <View className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]">
      {/* Top bar — back affordance clears the status bar */}
      <View className="px-2" style={{ paddingTop: insets.top + 4 }}>
        <Press
          onPress={onBack}
          haptic="light"
          pressedScale={0.97}
          accessibilityRole="button"
          accessibilityLabel="Go back"
          hitSlop={8}
          className="h-11 flex-row items-center gap-1 self-start px-2"
        >
          <Icon name="chevron-left" size="sm" color="#65A30D" />
          <Text className="text-base text-[#65A30D]">Back</Text>
        </Press>
      </View>

      <KeyboardAvoiding>
        <ScrollView
          showsVerticalScrollIndicator={false}
          keyboardShouldPersistTaps="handled"
          keyboardDismissMode="interactive"
          contentContainerClassName="flex-grow justify-center px-6"
          contentContainerStyle={{ paddingBottom: insets.bottom + 24 }}
        >
          <View className="gap-9">
            {/* Header — tinted icon tile + display title */}
            <Mount delay={0}>
              <View className="gap-3">
                {/* Message-in-a-ring — a delivered-mail motif, not a
                    generic security tile. */}
                <View className="h-16 w-16 items-center justify-center rounded-full border-2 border-[#65A30D]/30 bg-[#65A30D]/10">
                  <View className="h-11 w-11 items-center justify-center rounded-full bg-[#65A30D]">
                    <Icon name="mail" size="sm" color="#FFFFFF" />
                  </View>
                </View>
                <Text variant="h1" className="mt-3 tracking-tight">
                  Enter verification code
                </Text>
                <Text variant="muted" className="max-w-xs text-base leading-relaxed">
                  {email ? (
                    <>
                      We sent a {length}-digit code to{" "}
                      <Text className="font-semibold text-foreground">{email}</Text>
                      {". "}
                      <Text
                        className="text-[#65A30D] font-medium"
                        accessibilityRole="link"
                        onPress={() => {
                          haptics.light();
                          onBack?.();
                        }}
                      >
                        Edit
                      </Text>
                    </>
                  ) : (
                    `Enter the ${length}-digit code we sent you.`
                  )}
                </Text>
              </View>
            </Mount>

            {/* iOS autofill hint — the "From Messages" suggestion bar the
                keyboard shows. Tapping it fills the code (the same code the
                phone-verification preview showed). */}
            <Mount delay={BLOCK_STAGGER / 2}>
              <Pressable
                onPress={() => {
                  haptics.selection();
                  "481923".split("").slice(0, length).forEach((d, i) => setDigit(i, d));
                }}
                accessibilityRole="button"
                accessibilityLabel="Autofill code 481923 from messages"
                hitSlop={8}
                className="mb-4 flex-row items-center gap-2 self-center overflow-hidden rounded-full bg-white px-4 py-2 dark:bg-[#1A1A20]"
              >
                <Icon name="message-circle" size="sm" color="mutedForeground" />
                <Text variant="small" className="text-muted-foreground">
                  From Messages:{" "}
                  <Text className="font-semibold tracking-widest text-foreground">481 923</Text>
                </Text>
              </Pressable>
            </Mount>

            {/* Cells — flex-1 so the row fills any width evenly */}
            <Mount delay={BLOCK_STAGGER}>
              <View>
                <View
                  className="flex-row gap-2"
                  accessibilityRole="adjustable"
                  accessibilityLabel={`Verification code, ${digits.filter(Boolean).length} of ${length} digits entered`}
                >
                  {digits.map((d, i) => (
                    <OtpCell
                      key={i}
                      index={i}
                      value={d}
                      focused={i === focused}
                      hasError={!!error}
                      length={length}
                      inputRef={(r) => {
                        refs.current[i] = r;
                      }}
                      onChange={setDigit}
                      onKey={onKey}
                      onFocusCell={setFocused}
                    />
                  ))}
                </View>
                {error ? (
                  <Text
                    variant="small"
                    className="mt-3 text-destructive"
                    accessibilityLiveRegion="assertive"
                    accessibilityRole="alert"
                  >
                    {error}
                  </Text>
                ) : null}
              </View>
            </Mount>

            <Mount delay={BLOCK_STAGGER * 2}>
              <Button
                size="lg"
                loading={loading}
                disabled={!digits.every(Boolean)}
                onPress={() => onVerify?.(digits.join(""))}
                haptic="medium"
                className="bg-[#65A30D] w-full"
              >
                Verify
              </Button>
            </Mount>

            {/* Resend — countdown then a real link */}
            <Mount delay={BLOCK_STAGGER * 3}>
              <View className="items-center">
                {cooldown > 0 ? (
                  <Text variant="muted" style={{ fontVariant: ["tabular-nums"] }}>
                    Resend code in {cooldown}s
                  </Text>
                ) : (
                  <Press
                    onPress={() => {
                      setCooldown(resendCooldown);
                      onResend?.();
                    }}
                    haptic="light"
                    pressedScale={0.97}
                    accessibilityRole="button"
                    accessibilityLabel="Resend verification code"
                    hitSlop={8}
                    className="min-h-11 flex-row items-center justify-center gap-2 px-2"
                  >
                    <Icon name="refresh" size="sm" color="#65A30D" />
                    <Text className="text-[#65A30D] font-semibold" style={{ fontVariant: ["tabular-nums"] }}>
                      Resend code
                    </Text>
                  </Press>
                )}
              </View>
            </Mount>
          </View>
        </ScrollView>
      </KeyboardAvoiding>
    </View>
    </World>
  );
}

interface OtpCellProps {
  index: number;
  value: string;
  focused: boolean;
  hasError: boolean;
  length: number;
  inputRef: (r: TextInput | null) => void;
  onChange: (index: number, value: string) => void;
  onKey: (index: number, key: string) => void;
  onFocusCell: (index: number | null) => void;
}

/** One OTP digit — the border color glides input → ring on focus (same
 *  interpolateColor idiom as Input), the cell lifts slightly while focused,
 *  and a digit arrival pops the scale before settling. */
function OtpCell({ index, value, focused, hasError, length, inputRef, onChange, onKey, onFocusCell }: OtpCellProps) {
  const { colors } = useTheme();
  const reduce = useReducedMotion();
  /** Focus progression — drives the border glide and the lift. */
  const focusT = useSharedValue(focused ? 1 : 0);
  /** Scale driver — filled digits overshoot once, then settle to focus scale. */
  const scale = useSharedValue(1);
  const wasFilled = useRef(!!value);

  useEffect(() => {
    const filled = !!value;
    if (filled && !wasFilled.current && !reduce) scale.value = 1.1;
    wasFilled.current = filled;
    const target = focused ? 1.05 : 1;
    scale.value = reduce ? target : withSpring(target, spring.press);
  }, [value, focused, reduce, scale]);

  useEffect(() => {
    const target = focused ? 1 : 0;
    focusT.value = reduce ? target : withTiming(target, timing.fast);
  }, [focused, reduce, focusT]);

  const cellStyle = useAnimatedStyle(() => ({
    borderColor: hasError
      ? colors.destructive
      : interpolateColor(
          focusT.value,
          [0, 1],
          // Tonal at rest — the border matches the cell surface, so only
          // the focus ring glides in; no resting hairline.
          [value ? colors.muted : colors.card, colors.ring]
        ),
    transform: [{ scale: scale.value }],
  }));

  return (
    <View className="min-w-0 flex-1">
      <View className="absolute inset-0 rounded-2xl bg-white dark:bg-[#1A1A20]" pointerEvents="none" />
      <AnimatedTextInput
      ref={inputRef}
      value={value}
      onChangeText={(v) => onChange(index, v)}
      onKeyPress={({ nativeEvent }) => onKey(index, nativeEvent.key)}
      onFocus={() => onFocusCell(index)}
      onBlur={() => onFocusCell(null)}
      keyboardType="number-pad"
      textContentType="oneTimeCode"
      autoComplete="sms-otp"
      returnKeyType="done"
      // autoFocus removed to prevent auto-scrolling embedded previews
      selectTextOnFocus
      // Tabular figures keep each cell from jittering as digits change.
      style={[{ fontVariant: ["tabular-nums"] }, cellStyle]}
      className={cn(
        // min-w-0: web inputs carry an intrinsic size=20 min-width that
        // flex-1 can't shrink below — six cells would overflow the row.
        "h-16 w-full rounded-2xl border-[1.5px] border-transparent bg-transparent text-center text-2xl font-bold text-foreground",
        hasError && "bg-destructive/10"
      )}
      accessibilityLabel={`Digit ${index + 1} of ${length}`}
    />
    </View>
  );
}
OtpCell.displayName = "OtpCell";
```

Screen: https://lumioui.dev/screens/auth-otp-verification
Pixel 10 · Android
9:41
Live preview — the real screen via Expo Web · open full ↗