Skip to content
  • Introduction
    nativecn is shadcn for Expo apps: Components you copy into your app and own, built so AI agents can build and verify whole mobile apps.
  • Installation
    Create a new Expo app with nativecn, or add nativecn to an existing one.
  • components.json
    The project file that records your Preset, Structure, aliases and agents.
  • CLI
    Every nativecn-cli command and flag: init, create, add, agents and mcp.
  • Changelog
    What changed in each nativecn release.
  • Theming
    Tokens, Colour Roles, Scale, Scheme, createStyles and fonts.
  • Presets & Styles
    Style, Base Colour, Accent Colour, Radius and fonts: chosen once, baked into your code.
  • Folder structure
    Flat and feature Structures, Destinations and the placement rule.
  • AI agents
    Rules, Skills, the nativecn MCP server, the Plugin and the Visual QA Loop.
  • Alert
    Alert, AlertTitle and AlertDescription: an inline message with Variants (default, destructive, success, warning, info), each with a default icon.
  • Avatar
    Round user image (expo-image) with fallback initials, in sizes sm, md and lg.
  • Badge
    Small status label with Variants default, secondary, outline, destructive, success and warning.
  • Button
    Button with Variants (primary, secondary, outline, ghost, destructive, link), Sizes, an icon slot, loading and status; icon-only requires aria-label.
  • Card
    Card surface with CardHeader, CardTitle, CardDescription, CardContent and CardFooter; onPress makes the whole card pressable.
  • Checkbox
    Checkbox with an optional label, controlled or uncontrolled, an indeterminate (mixed) state and a haptic tick.
  • Chip
    Chip (plain, toggle or removable, with an optional icon) and ChipGroup (single or multiple choice on the SelectionGroup Primitive), with a haptic tick.
  • Container
    Wraps a Screen: safe-area edges, scrolling, keyboard avoidance, Token padding and a max content width.
  • Drawer
    Drawer panel content for Expo Router's drawer (expo-router/drawer): DrawerContent, DrawerHeader, DrawerSection, DrawerItem (label, icon, href, badge; active from the current route) and DrawerFooter.
  • Empty State
    What a Screen or list shows when there is nothing to show yet: an icon, a title, a description and an action.
  • FormField
    Lays out a control with its Label, description and error, and links them for screen readers ("Email, text field, Enter a valid email").
  • Icon
    Every glyph: a Lucide icon at a Token size in a Colour Role, decorative unless labelled.
  • Input
    Single-line text field with Sizes, a leading icon, error/success status, a focus ring and an automatic Show/Hide password toggle.
  • InputOTP
    One-time code field: one hidden input drives N cells; numeric keyboard, SMS/Keychain autofill, paste, onComplete, error/success status and secure.
  • Label
    A field's visible label in the label type Variant, with a required marker read as "required".
  • List
    Settings-style rows: ListSection, ListSectionHeader, ListSectionFooter and ListItem (title, description, icon, trailing, chevron, destructive, onPress); FlashList-safe.
  • Progress
    A horizontal progress bar: a value 0–100 that animates, or indeterminate; role progressbar with its value.
  • RadioGroup
    RadioGroup and RadioGroupItem: a single choice from a list on the SelectionGroup Primitive, vertical or horizontal, with a haptic tick.
  • SchemeSwitcher
    Theme switcher / dark mode toggle: System, Light and Dark as SegmentedTabs, or one icon button cycling through them. Calls setScheme, which persists the choice.
  • SearchField
    Search box with a search icon, an automatic clear (×) button, a loading spinner and onSubmit from the keyboard's Search key.
  • SegmentedTabs
    In-screen tabs (not navigation): segmented or underline Variants, an animated indicator, and only the selected tab's content rendered.
  • Separator
    A hairline divider, horizontal or vertical, decorative by default.
  • Skeleton
    A placeholder shape shown while content loads; pulses unless Reduce Motion is on, hidden from screen readers.
  • Slider
    Pick a number in a range by dragging or tapping (Gesture Handler + Reanimated), snapped to steps with a haptic tick; adjustable for screen readers.
  • Spinner
    An indeterminate loading indicator in a Colour Role, announced as busy; still under Reduce Motion.
  • Switch
    A themed on/off Switch, identical on iOS and Android, with an optional label, a thumb that slides (instant under Reduce Motion) and a haptic tick.
  • TabNavigation
    The bottom tab bar: the custom tabBar of an Expo Router JS Tabs Layout, with classic and floating Variants, per-tab icons and badges from Tabs.Screen options.
  • Text
    All text: the type ramp as Variants, text Colour Roles, alignment and the font-scaling switch.
  • Textarea
    Multi-line text field that grows from minRows to maxRows, with a character counter when maxLength is set.
  • Toast
    Sonner-style Toasts: a <Toaster /> in the root Layout and toast(), toast.success/error/info/warning/dismiss. At most 3 stacked, 4s auto-dismiss, swipe away, announced, above native modals.
  • Profile header + grouped sections
    Drawer panel with a profile header (avatar, name, email, plan Badge), labelled sections (Main, Workspace, Support) and Log out pinned in the footer.
  • SaaS workspace
    Drawer panel for a SaaS workspace: logo and name with a close button, an Accent Colour onboarding item with a 5 Steps Badge, main items, a bottom group (Notifications with a count, Settings, Docs, Help) and a footer with a team switcher and the user's avatar.
  • Cover header
    Drawer panel with a dark cover (large avatar, name, View profile, a bell with a count, an overflow button and a round + action), icon items active in Accent Colour text, a divider and text-only secondary items.
  • Classic centred
    A centred sign-in Screen: logo, email and password, Forgot password, an "or" divider, then Apple and Google, and a Sign up link.
  • Social-first hero
    A social-first sign-in Screen: a dark brand hero (logo, headline, subtitle, centred) above a panel where Apple and Google lead and "Continue with email" reveals the email form.
  • Email-first, two steps
    An email-first sign-in Screen in two steps: the email, then the password, with "Email me a code instead" switching to a 6-digit code (InputOTP). Lucide chevron-left Back buttons.
  • Classic single form
    A classic sign-up Screen on one scroll: Apple and Google on top, "or", then name, email and password, a terms Checkbox with Terms and Privacy Policy links, Create account, and a Sign in link.
  • Step-by-step wizard
    A sign-up wizard, one question per step under a Progress bar: email, name, password, then a 6-digit code (InputOTP). Lucide chevron-left Back buttons; the Continue button rides above the keyboard.
  • Social-first + verify
    A brand-led, passwordless sign-up Screen: the logo and headline centred above Apple and Google; "Sign up with email" reveals the email field, and Send code moves on to a 6-digit code (InputOTP).
  • announce
    Reliable screen-reader announcements on iOS and Android (Button and Input status, Toast, error, success and warning Alerts).
  • FocusChain
    Automatic Next/Done between fields in on-screen order; Done submits the form.
  • FormField context
    Links a field to its label, description and error for screen readers; passes status, disabled and required down.
  • Keyboard
    KeyboardProvider setup, a scroll view that keeps the focused field above the keyboard, and a footer that keeps a form's main button above it.
  • Portal
    Renders content above every Screen (above native modals on iOS), layered and safe-area aware.
  • Pressable
    Pressed look from a Style Slot, tap area extended to 48, no handlers while disabled or loading, optional haptics, role and aria-* states.
  • SelectionGroup
    Single or multiple choice within a group, with radio, tab or checkbox roles and selected states.
  • useControllableState
    One implementation of controlled and uncontrolled values for form controls.
  • useMotion
    Enter/exit animations and timing/spring configs from the motion Tokens; instant when Reduce Motion is on.
  • Theme
    Tokens, Scale, Colour Roles, ThemeProvider, createStyles and the navigation theme.

InputOTP

One-time code field: one hidden input drives N cells; numeric keyboard, SMS/Keychain autofill, paste, onComplete, error/success status and secure.

View as Markdown
Componentforms
Style

Light

Dark

Your app is ready.

Inter body text, Inter headings.

smmdlgxl
you@example.com
Continue
Default Preset a0: Vega, neutral, neutral accent, default radius, Inter.Change Preset

Installation

$npx nativecn-cli@latest add input-otp

Also installs: theme, text, form-field-context, focus-chain, use-controllable-state, announce.

Usage

InputOTP demo

A 6-digit verification code, checked when complete, with error and success.

import { useState } from "react";
import { View } from "react-native";

import { InputOTP, type InputOTPStatus } from "@/components/input-otp";
import { Text } from "@/components/text";
import { createStyles } from "@/theme";

const useStyles = createStyles((t) => ({ root: { gap: t.spacing[3] } }));

/** A 6-digit verification code: checked when complete; typing again clears the status. */
export function InputOTPDemo() {
  const styles = useStyles();
  const [code, setCode] = useState("");
  const [status, setStatus] = useState<InputOTPStatus>();

  return (
    <View style={styles.root}>
      <Text variant="label">Verification code</Text>
      <InputOTP
        aria-label="Verification code"
        value={code}
        onChangeText={(next) => {
          setCode(next);
          setStatus(undefined);
        }}
        onComplete={(full) => setStatus(full === "123456" ? "success" : "error")}
        status={status}
      />
      <Text variant="caption" color={status === "error" ? "destructive" : "mutedForeground"}>
        {status === "error" ? "That code is not right. Try 123456." : "Enter the code we sent you."}
      </Text>
    </View>
  );
}

Props

PropType and description
lengthnumber of digits (default 6)
valuestring (controlled)
defaultValuestring (uncontrolled, default "")
onChangeText(code: string) => void: digits only, on every change including a paste
onComplete(code: string) => void: once each time the last cell is filled
status'error' | 'success': colours every cell, announced once; the screen clears it
secureboolean: dots instead of digits, hidden from screen readers (default false)
disabledboolean: not editable, dimmed, skipped by FocusChain
refRef<TextInput>: the hidden input (focus, blur, clear)
styleViewStyle, layout only, merged last onto the root
...TextInputPropsother TextInput props pass through to the hidden input (autoFocus, onFocus, aria-label, accessibilityHint, ...)

Variants

  • status: error, success

Accessibility

  • Screen readers read one field, "Code, 6 digits": the name is aria-label, else the FormField label, else "Code", plus the digit count. The cells are hidden.
  • Inside a FormField the error, or else the description, is its hint.
  • A new status is announced once ("Code: error"), unless a FormField is already announcing its error.
  • With secure, the cells show dots and the hidden input is a secure text entry.
  • When the row of cells is under 48, the root extends the tap area to 48 and a tap there focuses the field.
  • disabled sets aria-disabled and blocks typing.

Notes

One hidden TextInput drives the cells, so typing, deleting, one-time-code autofill (iOS Keychain/SMS, Android SMS) and paste behave like one field. Non-digits are dropped, so a pasted "123 456" fills every cell. Inside FormField it takes the label, error and disabled; inside FocusChain it joins as one field. Cell size, radius and gap come from the input-otp.cell and input-otp.root Style Slots. Precedence: disabled > status.

Source

The Vega Style's code, exactly as add copies it into your app.

{components}/input-otp.tsx

import { useCallback, useEffect, useRef, useState, type Ref } from "react";
import {
  Pressable as RNPressable,
  TextInput,
  View,
  type Insets,
  type StyleProp,
  type TextInputProps,
  type ViewStyle,
} from "react-native";

import { useFocusChainField } from "@/components/primitives/focus-chain";
import { useFormField } from "@/components/primitives/form-field-context";
import { Text } from "@/components/text";
import { useControllableState } from "@/hooks/use-controllable-state";
import { createStyles, useTheme } from "@/theme";
import { announce } from "@/utils/announce";

export type InputOTPStatus = "error" | "success";

/** The border Colour Role of a cell in each state. */
const cellBorders = {
  idle: "input",
  active: "ring",
  error: "destructive",
  success: "success",
} as const;

/** The character shown in a filled cell while `secure` is on. */
const SECURE_DOT = "•";

export type InputOTPProps = Omit<
  TextInputProps,
  | "value"
  | "defaultValue"
  | "onChangeText"
  | "maxLength"
  | "secureTextEntry"
  | "multiline"
  | "style"
  | "editable"
> & {
  /** The number of digits (default `6`). */
  length?: number;
  /** The controlled code. */
  value?: string;
  /** The starting code while uncontrolled (default `""`). */
  defaultValue?: string;
  /** Called with the digits-only code on every change, including a paste. */
  onChangeText?: (code: string) => void;
  /** Called once with the code each time the last cell is filled. */
  onComplete?: (code: string) => void;
  /** Colours the cells and announces the change. The screen sets and clears it. */
  status?: InputOTPStatus;
  /** Shows a dot instead of each digit, and hides the code from screen readers. */
  secure?: boolean;
  /** Blocks typing and is announced as disabled. */
  disabled?: boolean;
  /** Layout only, merged last onto the root. */
  style?: StyleProp<ViewStyle>;
  /** The hidden TextInput that drives the cells (`focus()`, `blur()`, `clear()`). */
  ref?: Ref<TextInput>;
};

/** Keep digits only, at most `length` of them (a pasted "123 456" becomes "123456"). */
/**
 * The extra tap area around the row of cells so it reaches `min` (the 48 touch target) on both
 * axes. The hidden TextInput covers the row but has no hitSlop of its own.
 */
export function cellsHitSlop(
  cellWidth: number,
  cellHeight: number,
  gap: number,
  length: number,
  min: number,
): Insets | undefined {
  const rowWidth = length * cellWidth + Math.max(0, length - 1) * gap;
  const x = Math.max(0, (min - rowWidth) / 2);
  const y = Math.max(0, (min - cellHeight) / 2);
  return x === 0 && y === 0 ? undefined : { top: y, bottom: y, left: x, right: x };
}

export function sanitizeCode(text: string, length: number): string {
  return text.replace(/\D/g, "").slice(0, length);
}

const useStyles = createStyles((t) => ({
  root: { alignSelf: "flex-start" },
  cells: { ...{ gap: t.spacing[2] }, flexDirection: "row" },
  cell: {
    ...{
    width: t.scaleValue(48),
    height: t.scaleValue(56),
    borderRadius: t.radius.md,
  },
    alignItems: "center",
    justifyContent: "center",
    borderCurve: "continuous",
    borderWidth: t.borderWidth.default,
    backgroundColor: t.colors.background,
  },
  idle: { borderColor: t.colors[cellBorders.idle] },
  active: { borderColor: t.colors[cellBorders.active] },
  error: { borderColor: t.colors[cellBorders.error] },
  success: { borderColor: t.colors[cellBorders.success] },
  // The real TextInput covers the cells, so a tap focuses it and a long press offers Paste. Its
  // text and caret are transparent (not `opacity: 0`, which hides it from VoiceOver).
  input: {
    position: "absolute",
    top: 0,
    right: 0,
    bottom: 0,
    left: 0,
    color: "transparent",
    backgroundColor: "transparent",
    borderWidth: 0,
    padding: 0,
  },
  disabled: { opacity: t.opacity.disabled },
}));

/**
 * A one-time code field: one hidden TextInput drives `length` visible cells, so typing, deleting,
 * SMS/Keychain autofill (`one-time-code`) and paste all behave like one field. Numeric keyboard;
 * `onComplete` fires when the last cell is filled. Inside FormField it takes its label, status and
 * disabled state; inside FocusChain it joins as one field. Screen readers read one field:
 * "Code, 6 digits". Precedence: `disabled` > `status`.
 */
export function InputOTP({
  length = 6,
  value,
  defaultValue = "",
  onChangeText,
  onComplete,
  status,
  secure = false,
  disabled,
  style,
  ref,
  testID,
  onFocus,
  onBlur,
  returnKeyType,
  onSubmitEditing,
  submitBehavior,
  "aria-label": ariaLabel,
  accessibilityHint,
  ...props
}: InputOTPProps) {
  const field = useFormField();
  const styles = useStyles();
  const { minTouchTarget } = useTheme();

  const isDisabled = disabled ?? field?.disabled ?? false;
  const shownStatus = isDisabled ? undefined : (status ?? field?.status);

  const [raw, setCode] = useControllableState({
    value,
    defaultValue,
    onChange: onChangeText,
  });
  const code = sanitizeCode(raw, length);
  const [focused, setFocused] = useState(false);

  const inputRef = useRef<TextInput | null>(null);
  const setRefs = useCallback(
    (node: TextInput | null) => {
      inputRef.current = node;
      if (typeof ref === "function") ref(node);
      else if (ref) ref.current = node;
    },
    [ref],
  );

  const chain = useFocusChainField(inputRef, {
    disabled: isDisabled,
    returnKeyType,
    onSubmitEditing,
    submitBehavior,
  });

  const handleChangeText = (text: string) => {
    if (isDisabled) return;
    const next = sanitizeCode(text, length);
    if (next === code) return;
    setCode(next);
    if (next.length === length) onComplete?.(next);
  };

  const handleFocus: TextInputProps["onFocus"] = (event) => {
    setFocused(true);
    onFocus?.(event);
  };
  const handleBlur: TextInputProps["onBlur"] = (event) => {
    setFocused(false);
    onBlur?.(event);
  };

  // The spoken name: "Code, 6 digits" (or the FormField label, e.g. "Verification code, required").
  const name = ariaLabel ?? field?.accessibilityProps["aria-label"] ?? "Code";
  const spokenName = `${name}, ${length} digits`;
  const hint = accessibilityHint ?? field?.accessibilityProps.accessibilityHint;

  // Announce a new status once. A FormField already announces its own error text.
  const announced = useRef<InputOTPStatus | undefined>(undefined);
  useEffect(() => {
    if (shownStatus && shownStatus !== announced.current && !field?.error) {
      announce(`${name}: ${shownStatus}`);
    }
    announced.current = shownStatus;
  }, [shownStatus, name, field?.error]);

  const activeIndex = focused && !isDisabled ? Math.min(code.length, length - 1) : -1;

  // Known before layout (Style Slots), so the tap area reaches 48 from the first frame.
  const hitSlop = cellsHitSlop(
    styles.cell.width as number,
    styles.cell.height as number,
    (styles.cells.gap as number | undefined) ?? 0,
    length,
    minTouchTarget,
  );

  return (
    // A tap within the root's hitSlop focuses the hidden TextInput (taps on the cells reach it
    // directly). Screen readers skip the root and read the TextInput as one field.
    <RNPressable
      testID={testID}
      accessible={false}
      focusable={false}
      importantForAccessibility="no"
      disabled={isDisabled}
      hitSlop={hitSlop}
      onPress={isDisabled ? undefined : () => inputRef.current?.focus()}
      style={[styles.root, isDisabled ? styles.disabled : null, style]}
    >
      <View
        style={styles.cells}
        aria-hidden
        importantForAccessibility="no-hide-descendants"
        pointerEvents="none"
      >
        {Array.from({ length }, (_, i) => {
          const char = code[i];
          const border = shownStatus ?? (i === activeIndex ? "active" : "idle");
          return (
            <View
              key={i}
              testID={testID ? `${testID}-cell-${i}` : undefined}
              style={[styles.cell, styles[border]]}
            >
              {char != null ? <Text variant="h3">{secure ? SECURE_DOT : char}</Text> : null}
            </View>
          );
        })}
      </View>
      <TextInput
        ref={setRefs}
        testID={testID ? `${testID}-input` : undefined}
        value={code}
        onChangeText={handleChangeText}
        editable={!isDisabled}
        keyboardType="number-pad"
        inputMode="numeric"
        autoComplete="one-time-code"
        textContentType="oneTimeCode"
        secureTextEntry={secure}
        autoCorrect={false}
        spellCheck={false}
        caretHidden
        contextMenuHidden={false}
        selectionColor="transparent"
        underlineColorAndroid="transparent"
        aria-label={spokenName}
        aria-disabled={isDisabled}
        accessibilityHint={hint}
        onFocus={handleFocus}
        onBlur={handleBlur}
        {...chain}
        {...props}
        style={styles.input}
      />
    </RNPressable>
  );
}