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.

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.

View as Markdown
Componentfeedback
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 toast

Also installs: theme, portal, text, icon, button, use-motion, announce.

Packages: zustand@^5.0.15, react-native-gesture-handler, react-native-reanimated, react-native-worklets, lucide-react-native@^1.51.0, react-native-svg.

Usage

Toast demo

Each Toast Variant, a description and an action.

import { View } from "react-native";

import { Button } from "@/components/button";
import { toast } from "@/components/toast";
import { createStyles } from "@/theme";

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

// Needs <PortalHost /> and <Toaster /> in the root Layout (`init` and `create` add both).
export default function ToastDemo() {
  const styles = useStyles();
  return (
    <View style={styles.root}>
      <Button label="Default" variant="outline" onPress={() => toast("Draft saved")} />
      <Button label="Success" variant="outline" onPress={() => toast.success("Profile updated")} />
      <Button
        label="Error"
        variant="outline"
        onPress={() => toast.error("Wrong password", { description: "Check it and try again." })}
      />
      <Button
        label="With action"
        variant="outline"
        onPress={() =>
          toast("Message archived", {
            action: { label: "Undo", onPress: () => toast.info("Message restored") },
          })
        }
      />
      <Button label="Dismiss all" variant="ghost" onPress={() => toast.dismiss()} />
    </View>
  );
}

Props

Toaster

PropType and description
position"top" (default) | "bottom": the edge Toasts appear at; the newest is nearest it
hostNamethe PortalHost to render into (default: the root host)

toast

PropType and description
toast(title, options?)shows a default Toast and returns its id
toast.success / .error / .info / .warningsame signature, with the Variant's icon
toast.dismiss(id?)dismisses one Toast, or all of them without an id
options.descriptiona second, quieter line
options.durationms before it dismisses itself (default 4000; Infinity to keep it)
options.action{ label, onPress }: one button; pressing it also dismisses
options.idreuse an id to update a Toast in place

Variants

  • default, success, error, info, warning

Accessibility

  • Each Toast is announced when it appears and again when updated in place; success, error, info and warning Toasts start with "Success:", "Error:", "Info:" or "Warning:".
  • The title and description are read as one element with a Dismiss action; VoiceOver's escape gesture also dismisses it.
  • Enter, exit and reflow animations are off under Reduce Motion, which is followed live.
  • The action is a Button: role button, with a 48 tap area.

Notes

Render one <Toaster /> in the root Layout next to <PortalHost /> (inside ThemeProvider); init and create already do. Call toast() from anywhere, including outside React. Server errors and success messages go through toast(), never Alert.alert. At most 3 are visible, stacked, newest nearest the edge; more wait in the queue. Each auto-dismisses after 4s (paused while touched) and can be swiped sideways or towards its edge. Animations follow the motion Tokens. The surface uses the toast.root Style Slot.

Source

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

{components}/toast.tsx

import {
  CircleAlert,
  CircleCheck,
  Info,
  TriangleAlert,
  type LucideIcon,
} from "lucide-react-native";
import { useEffect, useRef, useState } from "react";
import {
  AccessibilityInfo,
  View,
  useWindowDimensions,
  type AccessibilityActionEvent,
} from "react-native";
import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
import Animated, {
  LinearTransition,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withSpring,
  withTiming,
} from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { create } from "zustand";

import { Button } from "@/components/button";
import { Icon } from "@/components/icon";
import { Portal, usePortalInsets } from "@/components/primitives/portal";
import { Text } from "@/components/text";
import { useMotion } from "@/hooks/use-motion";
import { createStyles, type ColorRole } from "@/theme";
import { announce } from "@/utils/announce";

// ---------------------------------------------------------------------------------------------
// The queue (Design System ADR 0001 allows zustand for it)
// ---------------------------------------------------------------------------------------------

export type ToastVariant = "default" | "success" | "error" | "info" | "warning";

/** A single button inside the Toast. Pressing it runs `onPress`, then dismisses the Toast. */
export type ToastAction = { label: string; onPress: () => void };

export type ToastOptions = {
  /** Reuse an id to update a Toast in place (e.g. "Saving…" → "Saved") instead of adding one. */
  id?: string;
  /** A second, quieter line under the title. */
  description?: string;
  /** Milliseconds before it dismisses itself (default 4000). `Infinity` keeps it until dismissed. */
  duration?: number;
  action?: ToastAction;
};

export type ToastData = {
  id: string;
  title: string;
  variant: ToastVariant;
  description?: string;
  duration: number;
  action?: ToastAction;
  /** Bumped when the Toast is updated in place, so it is announced again and its timer restarts. */
  version: number;
};

/** How long a Toast stays up, in ms. */
export const TOAST_DURATION = 4000;
/** How many Toasts are on screen at once; later ones wait in the queue. */
export const MAX_VISIBLE_TOASTS = 3;

type ToastState = { toasts: ToastData[] };

/** Every pending Toast, oldest first. The Toaster shows the newest `MAX_VISIBLE_TOASTS`. */
export const useToastStore = create<ToastState>()(() => ({ toasts: [] }));

let nextId = 0;

function show(title: string, variant: ToastVariant, options: ToastOptions = {}): string {
  const id = options.id ?? `toast-${++nextId}`;
  const { toasts } = useToastStore.getState();
  const existing = toasts.find((t) => t.id === id);
  const data: ToastData = {
    id,
    title,
    variant,
    description: options.description,
    duration: options.duration ?? TOAST_DURATION,
    action: options.action,
    version: existing ? existing.version + 1 : 0,
  };
  useToastStore.setState({
    toasts: existing ? toasts.map((t) => (t.id === id ? data : t)) : [...toasts, data],
  });
  return id;
}

function dismiss(id?: string): void {
  useToastStore.setState(({ toasts }) => ({
    toasts: id === undefined ? [] : toasts.filter((t) => t.id !== id),
  }));
}

type ToastFn = (title: string, options?: ToastOptions) => string;

/**
 * Show a Toast. Needs one `<Toaster />` in the root Layout. Returns the Toast's id.
 *
 * ```ts
 * toast("Saved");
 * toast.success("Profile updated");
 * toast.error("Wrong password", { description: "Check it and try again." });
 * toast("Message archived", { action: { label: "Undo", onPress: restore } });
 * toast.dismiss(id); // or toast.dismiss() for all
 * ```
 *
 * Server errors and success messages always go through `toast()`; never use `Alert.alert`.
 */
export const toast: ToastFn & {
  success: ToastFn;
  error: ToastFn;
  info: ToastFn;
  warning: ToastFn;
  /** Dismiss one Toast by id, or every Toast when called without one. */
  dismiss: (id?: string) => void;
} = Object.assign((title: string, options?: ToastOptions) => show(title, "default", options), {
  success: (title: string, options?: ToastOptions) => show(title, "success", options),
  error: (title: string, options?: ToastOptions) => show(title, "error", options),
  info: (title: string, options?: ToastOptions) => show(title, "info", options),
  warning: (title: string, options?: ToastOptions) => show(title, "warning", options),
  dismiss,
});

// ---------------------------------------------------------------------------------------------
// The Toaster
// ---------------------------------------------------------------------------------------------

const variants = {
  default: { icon: undefined, color: "foreground", spoken: "" },
  success: { icon: CircleCheck, color: "success", spoken: "Success" },
  error: { icon: CircleAlert, color: "destructive", spoken: "Error" },
  info: { icon: Info, color: "info", spoken: "Info" },
  warning: { icon: TriangleAlert, color: "warning", spoken: "Warning" },
} as const satisfies Record<
  ToastVariant,
  { icon: LucideIcon | undefined; color: ColorRole; spoken: string }
>;

/** What the screen reader says for a Toast, e.g. "Error: Wrong password. Check it and try again." */
function spokenText({ title, description, variant }: ToastData): string {
  const prefix = variants[variant].spoken;
  const text = description ? `${title}. ${description}` : title;
  return prefix ? `${prefix}: ${text}` : text;
}

/** Toasts draw above every other Portal (Dialogs, Sheets, menus). */
const TOAST_LAYER = 1000;
/** A swipe past this share of the screen width, or a fling, dismisses the Toast. */
const SWIPE_DISMISS_RATIO = 0.25;
const SWIPE_DISMISS_VELOCITY = 800;

const useStyles = createStyles((t) => ({
  viewport: {
    position: "absolute",
    left: 0,
    right: 0,
    alignItems: "center",
    gap: t.spacing[2],
  },
  frame: { width: "100%", maxWidth: t.scaleValue(560) },
  // Overrides the gesture root's default `flex: 1`, so it sizes to the Toast.
  gestureRoot: { flex: 0 },
  root: {
    ...{
    borderRadius: t.radius.xl,
    padding: t.spacing[4],
    boxShadow: t.elevation.lg,
  },
    flexDirection: "row",
    alignItems: "center",
    gap: t.spacing[3],
    backgroundColor: t.colors.popover,
    borderWidth: t.borderWidth.default,
    borderColor: t.colors.border,
    borderCurve: "continuous",
  },
  content: { flex: 1, gap: t.spacing[0.5] },
  edge: { padding: t.spacing[2], paddingHorizontal: t.spacing[4] },
}));

export type ToasterProps = {
  /** Which edge the Toasts appear at (default `top`). The newest is nearest the edge. */
  position?: "top" | "bottom";
  /** The PortalHost to render into (default: the root host). */
  hostName?: string;
};

/**
 * Shows the Toasts queued by `toast()`. Render it once in the root Layout, next to the
 * `PortalHost`: it draws through the Portal above every Screen (and above native modals on iOS),
 * clear of the safe area. At most three show at once, stacked; each dismisses itself after its
 * `duration`, can be swiped away, and is announced to screen readers.
 */
export function Toaster({ position = "top", hostName }: ToasterProps) {
  return (
    <Portal hostName={hostName} layer={TOAST_LAYER}>
      <ToastViewport position={position} />
    </Portal>
  );
}

/** Reanimated reads Reduce Motion once at launch; this follows the setting live. */
function useLiveReduceMotion(): boolean | undefined {
  const [enabled, setEnabled] = useState<boolean | undefined>(undefined);
  useEffect(() => {
    let active = true;
    AccessibilityInfo.isReduceMotionEnabled()
      .then((value) => {
        if (active) setEnabled(value);
      })
      .catch(() => {});
    const subscription = AccessibilityInfo.addEventListener("reduceMotionChanged", setEnabled);
    return () => {
      active = false;
      subscription.remove();
    };
  }, []);
  return enabled;
}

function ToastViewport({ position }: { position: "top" | "bottom" }) {
  const styles = useStyles();
  const insets = usePortalInsets();
  const toasts = useToastStore((s) => s.toasts);
  const motion = useMotion();
  const reduced = useLiveReduceMotion() ?? motion.reduced;

  // The newest few are shown; the newest sits nearest the edge.
  const visible = toasts.slice(-MAX_VISIBLE_TOASTS);
  const ordered = position === "top" ? [...visible].reverse() : visible;

  // A plain View: taps between and around the Toasts pass through to the Screen. Never wrap this
  // in a gesture root: Android ignores `pointerEvents` on one, so it would take every tap (#153).
  return (
    <View
      pointerEvents="box-none"
      testID="toaster"
      style={[
        styles.viewport,
        styles.edge,
        position === "top" ? { top: insets.top } : { bottom: insets.bottom },
        { marginLeft: insets.left, marginRight: insets.right },
      ]}
    >
      {ordered.map((item) => (
        <ToastItem key={item.id} toast={item} position={position} reduced={reduced} />
      ))}
    </View>
  );
}

function ToastItem({
  toast: item,
  position,
  reduced,
}: {
  toast: ToastData;
  position: "top" | "bottom";
  reduced: boolean;
}) {
  const styles = useStyles();
  const motion = useMotion();
  const { width } = useWindowDimensions();
  const { id, version, duration, action } = item;
  const variant = variants[item.variant];
  const spoken = spokenText(item);

  // Announced when it appears and again when updated in place.
  useEffect(() => {
    announce(spoken);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [id, version]);

  // Auto-dismiss, paused while a finger is on the Toast and resumed with the time left.
  const [paused, setPaused] = useState(false);
  const remaining = useRef(duration);
  useEffect(() => {
    remaining.current = duration;
  }, [version, duration]);
  useEffect(() => {
    if (paused || !Number.isFinite(duration)) return;
    const started = Date.now();
    const timer = setTimeout(() => dismiss(id), Math.max(0, remaining.current));
    return () => {
      clearTimeout(timer);
      remaining.current -= Date.now() - started;
    };
  }, [paused, id, version, duration]);

  // Swipe sideways (either way) or towards the edge to dismiss.
  const towardEdge = position === "top" ? -1 : 1;
  const x = useSharedValue(0);
  const y = useSharedValue(0);
  const remove = () => dismiss(id);
  const pause = (on: boolean) => setPaused(on);
  const exit = reduced
    ? { duration: 0, reduceMotion: ReduceMotion.Always }
    : motion.timing("fast", "exit");
  const settle = reduced
    ? { duration: 0, reduceMotion: ReduceMotion.Always }
    : motion.spring("snappy");

  const pan = Gesture.Pan()
    .withTestId(`toast-pan-${id}`)
    .activeOffsetX([-10, 10])
    .activeOffsetY([-10, 10])
    .onBegin(() => {
      scheduleOnRN(pause, true);
    })
    .onUpdate((e) => {
      x.value = e.translationX;
      // Only towards the edge; the other way is resisted.
      y.value = towardEdge * e.translationY > 0 ? e.translationY : e.translationY / 4;
    })
    .onEnd((e) => {
      const sideways =
        Math.abs(e.translationX) > width * SWIPE_DISMISS_RATIO ||
        Math.abs(e.velocityX) > SWIPE_DISMISS_VELOCITY;
      const offEdge =
        towardEdge * e.translationY > width * SWIPE_DISMISS_RATIO * 0.5 ||
        towardEdge * e.velocityY > SWIPE_DISMISS_VELOCITY;
      if (sideways) {
        x.value = withTiming(Math.sign(e.translationX || e.velocityX) * width, exit, (done) => {
          if (done) scheduleOnRN(remove);
        });
      } else if (offEdge) {
        y.value = withTiming(towardEdge * width, exit, (done) => {
          if (done) scheduleOnRN(remove);
        });
      } else {
        x.value = withSpring(0, settle);
        y.value = withSpring(0, settle);
      }
    })
    .onFinalize(() => {
      scheduleOnRN(pause, false);
    });

  const swipeStyle = useAnimatedStyle(() => ({
    opacity: 1 - Math.min(1, Math.abs(x.value) / width),
    transform: [{ translateX: x.value }, { translateY: y.value }],
  }));

  const transition = position === "top" ? "from-top" : "from-bottom";
  const onAccessibilityAction = (event: AccessibilityActionEvent) => {
    if (event.nativeEvent.actionName === "dismiss" || event.nativeEvent.actionName === "escape")
      remove();
  };

  return (
    // Enter/exit/reflow animate the wrapper; the swipe moves the surface inside it.
    <Animated.View
      entering={reduced ? undefined : motion.entering(transition)}
      exiting={reduced ? undefined : motion.exiting(transition)}
      layout={reduced ? undefined : LinearTransition.duration(motion.duration("base"))}
      style={styles.frame}
    >
      {/* The swipe needs a gesture root above it, and the app's root Layout may not have one. This
          one is only as big as the Toast, so it takes no taps meant for the Screen; inside another
          gesture root it simply defers to it. */}
      <GestureHandlerRootView style={styles.gestureRoot}>
        <GestureDetector gesture={pan}>
          <Animated.View testID={`toast-${id}`} style={[styles.root, swipeStyle]}>
            {variant.icon ? <Icon icon={variant.icon} color={variant.color} /> : null}
            <View
              style={styles.content}
              accessible
              aria-label={spoken}
              accessibilityActions={[
                { name: "dismiss", label: "Dismiss" },
                { name: "escape", label: "Dismiss" },
              ]}
              onAccessibilityAction={onAccessibilityAction}
              onAccessibilityEscape={remove}
            >
              <Text variant="label" color="popoverForeground">
                {item.title}
              </Text>
              {item.description ? (
                <Text variant="small" color="mutedForeground">
                  {item.description}
                </Text>
              ) : null}
            </View>
            {action ? (
              <Button
                label={action.label}
                size="sm"
                variant="secondary"
                onPress={() => {
                  action.onPress();
                  remove();
                }}
              />
            ) : null}
          </Animated.View>
        </GestureDetector>
      </GestureHandlerRootView>
    </Animated.View>
  );
}