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.

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.

View as Markdown
Componentnavigation
Style

Light

Dark

Your app is ready.

Inter body text, Inter headings.

smmdlgxl
you@example.com
Continue
This needs Expo Router (a navigation Layout), so it can't run in the browser. The card shows your Preset's Tokens; run it in an app to see it live.Default Preset a0: Vega, neutral, neutral accent, default radius, Inter.Change Preset

Installation

$npx nativecn-cli@latest add tab-navigation

Also installs: theme, pressable, text, icon, badge.

Packages: lucide-react-native@^1.51.0, react-native-svg, react-native-safe-area-context.

Usage

TabNavigation demo

A tabs Layout using TabNavigation (floating) as the tabBar of Expo Router's JS Tabs, with icons and a badge.

import { Tabs } from "expo-router/js-tabs";
import { Bell, House, Search, User } from "lucide-react-native";

import { TabNavigation, tabIcon } from "@/components/tab-navigation";

// A tabs Layout, e.g. app/(tabs)/_layout.tsx: Expo Router's JS Tabs with TabNavigation as its
// tab bar. Each Tabs.Screen gives its title, icon and (optional) badge.
export default function TabsLayout() {
  return (
    <Tabs tabBar={(props) => <TabNavigation {...props} variant="floating" />}>
      <Tabs.Screen name="index" options={{ title: "Home", tabBarIcon: tabIcon(House) }} />
      <Tabs.Screen name="search" options={{ title: "Search", tabBarIcon: tabIcon(Search) }} />
      <Tabs.Screen
        name="inbox"
        options={{ title: "Inbox", tabBarIcon: tabIcon(Bell), tabBarBadge: 3 }}
      />
      <Tabs.Screen name="profile" options={{ title: "Profile", tabBarIcon: tabIcon(User) }} />
    </Tabs>
  );
}

Props

PropType and description
...BottomTabBarPropsstate, descriptors, navigation, insets: what Expo Router's JS Tabs pass to tabBar; spread them in
variant"classic" (default): full-width bar with a top border | "floating": inset pill with the Style's radius and a large shadow, above the bottom safe area
options.title / options.tabBarLabelTabs.Screen option: the tab's label and accessible name (a string, or a render function)
options.tabBarIconTabs.Screen option: ({ focused, color, size }) => ReactNode; use tabIcon(LucideIcon) to draw it with Icon
options.tabBarBadgeTabs.Screen option: number | string shown as a destructive Badge on the icon and spoken ("Inbox, 3 new")
options.tabBarAccessibilityLabelTabs.Screen option: replaces the spoken name
options.tabBarButtonTestIDTabs.Screen option: the tab's testID
options.tabBarShowLabelTabs.Screen option: false hides the label (default true)
options.tabBarStyle{ display: 'none' } on a Screen hides the bar while it is focused
tabIcon(icon)turns a Lucide icon into a tabBarIcon option, drawn with Icon
styleViewStyle, layout only, merged last onto the root

Variants

  • variant: classic, floating

Accessibility

  • The bar is a tablist; each tab has role tab, with aria-selected on the active one.
  • A tab's name is its label plus its badge ("Inbox, 3 new" for a count); tabBarAccessibilityLabel replaces it.
  • Each tab's tap area is at least 48 × 48.
  • String labels use the caption text Variant, which caps the OS font scale at 1.5× when font scaling is on.

Notes

Use it only as the tabBar of Expo Router's JavaScript Tabs (import { Tabs } from "expo-router/js-tabs"), never with NativeTabs and never for in-screen tabs (that is SegmentedTabs): <Tabs tabBar={(props) => <TabNavigation {...props} variant="floating" />}>. Each Tabs.Screen gives its title, tabBarIcon and optional tabBarBadge; Screens with href: null are skipped. A press emits tabPress (call e.preventDefault() in a listener to stop it), then navigates; a long press emits tabLongPress. The active tab is drawn in primary, the others in mutedForeground; the bar uses the card and border Colour Roles. Sizes come from the tab-navigation.bar, .icon, .label, .floating and .pressed Style Slots.

Source

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

{components}/tab-navigation.tsx

import type { BottomTabBarProps } from "expo-router/js-tabs";
import type { LucideIcon } from "lucide-react-native";
import { use, type ReactNode, type Ref } from "react";
import { StyleSheet, View, type ViewProps } from "react-native";
import { SafeAreaInsetsContext } from "react-native-safe-area-context";

import { Badge } from "@/components/badge";
import { Icon, type IconSize } from "@/components/icon";
import { Pressable } from "@/components/primitives/pressable";
import { Text, type TextColor } from "@/components/text";
import { createStyles, useTheme } from "@/theme";

/** Each Variant's look: a full-width bar, or an inset pill floating above the bottom edge. */
const variants = {
  classic: { inset: false },
  floating: { inset: true },
} as const satisfies Record<string, { inset: boolean }>;

export type TabNavigationVariant = keyof typeof variants;

/** Tab content colours: the active tab in `primary`, the others in `mutedForeground`. */
const ACTIVE: TextColor = "primary";
const INACTIVE: TextColor = "mutedForeground";

/**
 * Expo Router's tab-bar props (what `<Tabs tabBar={(props) => ...}>` passes) plus `variant`.
 * Per-tab icon, label and badge come from each `Tabs.Screen`'s options.
 */
export type TabNavigationProps = BottomTabBarProps &
  Omit<ViewProps, "children" | "role"> & {
    /** `classic`: full-width bar with a top border; `floating`: inset pill (default `classic`). */
    variant?: TabNavigationVariant;
    ref?: Ref<View>;
  };

const useStyles = createStyles((t) => {
  const bar = { height: t.scaleValue(64) };
  return {
    classic: {
      backgroundColor: t.colors.card,
      borderTopWidth: t.borderWidth.default,
      borderTopColor: t.colors.border,
    },
    floatingOuter: {
      paddingHorizontal: t.spacing[4],
      paddingTop: t.spacing[2],
    },
    floating: {
      ...{ borderRadius: t.radius["2xl"] },
      backgroundColor: t.colors.card,
      borderWidth: t.borderWidth.default,
      borderColor: t.colors.border,
      borderCurve: "continuous",
      boxShadow: t.elevation.lg,
      paddingHorizontal: t.spacing[2],
    },
    list: { ...bar, flexDirection: "row", alignItems: "stretch" },
    tab: {
      flex: 1,
      alignItems: "center",
      justifyContent: "center",
      gap: t.spacing[1],
    },
    icon: {
      ...{ width: t.iconSize.lg, height: t.iconSize.lg },
      alignItems: "center",
      justifyContent: "center",
    },
    label: {
    ...t.type.caption,
    fontSize: t.scaleValue(11),
    lineHeight: t.scaleValue(14),
  },
    pressed: { filter: [{ brightness: 0.88 }] },
    // The Badge sits on the icon's top end corner (layout only).
    badge: { position: "absolute", top: -t.spacing[2], start: "60%" },
  };
});

/** A tab's glyph through Icon: the Style's icon size, `primary` when active, muted otherwise. */
function TabIcon({ icon, focused, size }: { icon: LucideIcon; focused: boolean; size: number }) {
  const { iconSize } = useTheme();
  const token: IconSize = size >= iconSize.lg ? "lg" : size >= iconSize.md ? "md" : "sm";
  return <Icon icon={icon} size={token} color={focused ? ACTIVE : INACTIVE} />;
}

/**
 * Turns a Lucide icon into a `tabBarIcon` option, drawn with Icon at the Style's tab icon size:
 * `<Tabs.Screen name="index" options={{ title: "Home", tabBarIcon: tabIcon(House) }} />`.
 */
export function tabIcon(icon: LucideIcon) {
  return function TabBarIcon({ focused, size }: { focused: boolean; size: number }) {
    return <TabIcon icon={icon} focused={focused} size={size} />;
  };
}

/** The spoken badge: "3 new" for a count, the text itself otherwise ("New", "!"). */
function badgeText(badge: string | number): string {
  return typeof badge === "number" ? `${badge} new` : badge;
}

const isHidden = (style: unknown) =>
  (StyleSheet.flatten(style as never) as { display?: string } | undefined)?.display === "none";

/**
 * The app's bottom tab bar: the custom `tabBar` of an Expo Router JS Tabs Layout
 * (`expo-router/js-tabs`, never NativeTabs). Each `Tabs.Screen`'s `title`/`tabBarLabel`,
 * `tabBarIcon`, `tabBarBadge`, `tabBarAccessibilityLabel` and `tabBarButtonTestID` are read;
 * routes hidden with `href: null` are skipped. Presses emit `tabPress` (cancellable) and then
 * navigate, long presses emit `tabLongPress`, like the default tab bar. `style` is merged last
 * onto the root.
 */
export function TabNavigation({
  state,
  descriptors,
  navigation,
  insets,
  variant = "classic",
  style,
  ...props
}: TabNavigationProps) {
  const t = useTheme();
  const styles = useStyles();
  const safeArea = use(SafeAreaInsetsContext);
  const { inset } = variants[variant];

  // Expo Router passes the safe-area insets; the context covers a bar rendered on its own.
  const edges = {
    bottom: insets?.bottom ?? safeArea?.bottom ?? 0,
    left: insets?.left ?? safeArea?.left ?? 0,
    right: insets?.right ?? safeArea?.right ?? 0,
  };

  const focusedRoute = state.routes[state.index];
  if (focusedRoute && isHidden(descriptors[focusedRoute.key]?.options.tabBarStyle)) return null;

  const height = styles.list.height;

  const tabs = state.routes.map((route, index) => {
    const descriptor = descriptors[route.key];
    if (!descriptor) return null;
    const { options } = descriptor;
    if (isHidden(options.tabBarItemStyle)) return null;

    const focused = index === state.index;
    const color = focused ? ACTIVE : INACTIVE;
    const title = options.title ?? route.name;
    const labelText = typeof options.tabBarLabel === "string" ? options.tabBarLabel : title;
    const showLabel = options.tabBarShowLabel !== false;
    const badge = options.tabBarBadge;
    const name =
      options.tabBarAccessibilityLabel ??
      (badge != null && badge !== "" ? `${labelText}, ${badgeText(badge)}` : labelText);

    const onPress = () => {
      const event = navigation.emit({
        type: "tabPress",
        target: route.key,
        canPreventDefault: true,
      });
      if (!focused && !event.defaultPrevented) navigation.navigate(route.name, route.params);
    };
    const onLongPress = () => {
      navigation.emit({ type: "tabLongPress", target: route.key });
    };

    let label: ReactNode = null;
    if (showLabel) {
      label =
        typeof options.tabBarLabel === "function" ? (
          options.tabBarLabel({
            focused,
            color: t.colors[color],
            position: "below-icon",
            children: title,
          })
        ) : (
          <Text variant="caption" color={color} numberOfLines={1} style={styles.label}>
            {labelText}
          </Text>
        );
    }

    return (
      <Pressable
        key={route.key}
        role="tab"
        aria-selected={focused}
        aria-label={name}
        testID={options.tabBarButtonTestID}
        // Tabs share the row, so each is at least as wide as the bar is high (>= 48).
        size={{ width: height, height }}
        pressedStyle={styles.pressed}
        onPress={onPress}
        onLongPress={onLongPress}
        style={styles.tab}
      >
        <View style={styles.icon}>
          {options.tabBarIcon?.({
            focused,
            color: t.colors[color],
            size: styles.icon.width,
          })}
          {badge != null && badge !== "" ? (
            <Badge label={String(badge)} variant="destructive" style={styles.badge} />
          ) : null}
        </View>
        {label}
      </Pressable>
    );
  });

  const list = (
    <View role="tablist" style={styles.list}>
      {tabs}
    </View>
  );

  if (!inset) {
    return (
      <View
        style={[
          styles.classic,
          { paddingBottom: edges.bottom, paddingLeft: edges.left, paddingRight: edges.right },
          style,
        ]}
        {...props}
      >
        {list}
      </View>
    );
  }

  return (
    <View
      style={[
        styles.floatingOuter,
        // The pill floats one spacing step above the bottom safe area.
        {
          paddingBottom: edges.bottom + t.spacing[2],
          marginLeft: edges.left,
          marginRight: edges.right,
        },
        style,
      ]}
      {...props}
    >
      <View style={styles.floating}>{list}</View>
    </View>
  );
}