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.

FocusChain

Automatic Next/Done between fields in on-screen order; Done submits the form.

View as Markdown
Primitiveforms
Style

Installation

$npx nativecn-cli@latest add focus-chain

Props

FocusChain

PropType and description
childrenthe form's fields
onSubmit() => void: called when "Done" is pressed on the last field

useFocusChainField

PropType and description
useFocusChainField(ref, options?)registers a field and returns { returnKeyType, onSubmitEditing, submitBehavior } to spread onto its TextInput
options.disabled / options.hiddenboolean: the chain skips the field
options.multilineboolean: skipped so Enter keeps adding new lines (Textarea)
options.returnKeyType / options.onSubmitEditing / options.submitBehaviorthe field's own values win over the chain's

Notes

Wrap a form's fields in FocusChain: each field's return key reads Next and moves focus to the next field in on-screen order; the last reads Done and calls onSubmit. Input joins automatically; Textarea is skipped. Custom fields join with useFocusChainField.

Source

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

{components}/primitives/focus-chain.tsx

import {
  createContext,
  use,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  useSyncExternalStore,
  type ReactNode,
  type RefObject,
} from "react";
import type { ReturnKeyTypeOptions, SubmitBehavior, TextInputProps } from "react-native";

/** Anything the chain can move focus to (a TextInput, or a Component exposing `focus()`). */
export type Focusable = { focus: () => void };

type Entry = { key: string; ref: RefObject<Focusable | null>; eligible: boolean };

function createChainStore() {
  let entries: Entry[] = [];
  const listeners = new Set<() => void>();
  const emit = () => listeners.forEach((l) => l());
  return {
    subscribe(listener: () => void) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
    register(key: string, ref: RefObject<Focusable | null>) {
      entries = [...entries.filter((e) => e.key !== key), { key, ref, eligible: false }];
      emit();
    },
    update(key: string, eligible: boolean) {
      if (!entries.some((e) => e.key === key && e.eligible !== eligible)) return;
      entries = entries.map((e) => (e.key === key ? { ...e, eligible } : e));
      emit();
    },
    unregister(key: string) {
      entries = entries.filter((e) => e.key !== key);
      emit();
    },
    lastKey(): string | undefined {
      for (let i = entries.length - 1; i >= 0; i--)
        if (entries[i]!.eligible) return entries[i]!.key;
      return undefined;
    },
    nextAfter(key: string): Entry | undefined {
      const i = entries.findIndex((e) => e.key === key);
      return i === -1 ? undefined : entries.slice(i + 1).find((e) => e.eligible);
    },
  };
}

type ChainStore = ReturnType<typeof createChainStore>;
type ChainContextValue = { store: ChainStore; submit: () => void };

const FocusChainContext = createContext<ChainContextValue | null>(null);

export type FocusChainProps = {
  children?: ReactNode;
  /** Called when "Done" is pressed on the last field. */
  onSubmit?: () => void;
};

/**
 * Links the text fields inside it: every field's return key reads "Next" and moves to the
 * following field; the last one reads "Done" and calls `onSubmit`. Disabled, hidden and
 * multiline (Textarea) fields are skipped. Renders no views.
 *
 * Order: fields join in the order they mount, which is render (tree) order, and for a normal
 * top-to-bottom form also on-screen order. A field that mounts later (e.g. rendered
 * conditionally) joins at the end of the chain; to keep it in place, render it always and
 * toggle `hidden` instead.
 */
export function FocusChain({ children, onSubmit }: FocusChainProps) {
  const onSubmitRef = useRef(onSubmit);
  useLayoutEffect(() => {
    onSubmitRef.current = onSubmit;
  });
  const [value] = useState<ChainContextValue>(() => ({
    store: createChainStore(),
    submit: () => onSubmitRef.current?.(),
  }));
  return <FocusChainContext value={value}>{children}</FocusChainContext>;
}

export type FocusChainFieldOptions = {
  disabled?: boolean;
  /** The field is not visible; the chain skips it. */
  hidden?: boolean;
  /** Multiline fields (Textarea) are skipped so Enter keeps adding a new line. */
  multiline?: boolean;
  /** The field's own value wins over the chain's. */
  returnKeyType?: ReturnKeyTypeOptions;
  /** The field's own handler wins over the chain's. */
  onSubmitEditing?: TextInputProps["onSubmitEditing"];
  submitBehavior?: SubmitBehavior;
};

export type FocusChainFieldProps = {
  returnKeyType?: ReturnKeyTypeOptions;
  onSubmitEditing?: TextInputProps["onSubmitEditing"];
  submitBehavior?: SubmitBehavior;
};

const noopSubscribe = () => () => {};
const noLastKey = () => undefined;

/**
 * Joins a text field to the enclosing FocusChain. Spread the result onto the TextInput.
 * Outside a chain, or when the field is skipped, it returns the field's own props unchanged.
 */
export function useFocusChainField(
  ref: RefObject<Focusable | null>,
  options: FocusChainFieldOptions = {},
): FocusChainFieldProps {
  const { disabled, hidden, multiline, returnKeyType, onSubmitEditing, submitBehavior } = options;
  const chain = use(FocusChainContext);
  const key = useId();
  const eligible = !disabled && !hidden && !multiline;

  const store = chain?.store;
  useLayoutEffect(() => {
    if (!store) return;
    // Joins as ineligible; the effect below (same commit) sets eligibility.
    store.register(key, ref);
    return () => store.unregister(key);
  }, [store, key, ref]);
  useLayoutEffect(() => {
    store?.update(key, eligible);
  }, [store, key, eligible]);

  const lastKey = useSyncExternalStore(
    store?.subscribe ?? noopSubscribe,
    store?.lastKey ?? noLastKey,
    store?.lastKey ?? noLastKey,
  );

  const own = { returnKeyType, onSubmitEditing, submitBehavior };
  if (!chain || !eligible) return own;

  const isLast = lastKey === key;
  const chainSubmit: TextInputProps["onSubmitEditing"] = () => {
    const next = chain.store.nextAfter(key);
    if (next) next.ref.current?.focus();
    else chain.submit();
  };
  return {
    returnKeyType: returnKeyType ?? (isLast ? "done" : "next"),
    onSubmitEditing: onSubmitEditing ?? chainSubmit,
    // Keep the keyboard up while moving to the next field; the last field blurs as usual.
    submitBehavior: submitBehavior ?? (onSubmitEditing || isLast ? undefined : "submit"),
  };
}