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.
Light
Dark
Your app is ready.
Inter body text, Inter headings.
a0: Vega, neutral, neutral accent, default radius, Inter.Change PresetInstallation
npx nativecn-cli@latest add tab-navigationAlso 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>
);
}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
| 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
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>
);
}{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(52) };
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.xl },
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.md, height: t.iconSize.md },
alignItems: "center",
justifyContent: "center",
},
label: {
...t.type.caption,
fontSize: t.scaleValue(10),
lineHeight: t.scaleValue(12),
},
pressed: {
filter: [{ brightness: 0.88 }],
transform: [{ scale: 0.98 }],
},
// 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>
);
}
