# 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.

Component · navigation. Registry item `tab-navigation`, shown in the vega Style. JSON: https://nativecn.dev/r/styles/vega/tab-navigation.json

## Installation

```sh
npx nativecn-cli@latest add tab-navigation
```

Also installs: [theme](https://nativecn.dev/docs/theme/theme), [pressable](https://nativecn.dev/docs/primitives/pressable), [text](https://nativecn.dev/docs/components/text), [icon](https://nativecn.dev/docs/components/icon), [badge](https://nativecn.dev/docs/components/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.

```tsx
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

| Prop | Type and description |
|---|---|
| `...BottomTabBarProps` | state, 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.tabBarLabel` | Tabs.Screen option: the tab's label and accessible name (a string, or a render function) |
| `options.tabBarIcon` | Tabs.Screen option: ({ focused, color, size }) => ReactNode; use tabIcon(LucideIcon) to draw it with Icon |
| `options.tabBarBadge` | Tabs.Screen option: number \| string shown as a destructive Badge on the icon and spoken ("Inbox, 3 new") |
| `options.tabBarAccessibilityLabel` | Tabs.Screen option: replaces the spoken name |
| `options.tabBarButtonTestID` | Tabs.Screen option: the tab's testID |
| `options.tabBarShowLabel` | Tabs.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 |
| `style` | ViewStyle, 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

### {components}/tab-navigation.tsx

```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>
  );
}
```
