7.8 KiB
Native Tabs
Always prefer NativeTabs from 'expo-router/unstable-native-tabs' for the best iOS experience.
Requires SDK 54+
Basic Usage
import {
NativeTabs,
Icon,
Label,
Badge,
} from "expo-router/unstable-native-tabs";
export default function TabLayout() {
return (
<NativeTabs minimizeBehavior="onScrollDown">
<NativeTabs.Trigger name="index">
<Label>Home</Label>
<Icon sf="house.fill" />
<Badge>9+</Badge>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="settings">
<Icon sf="gear" />
<Label>Settings</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(search)" role="search">
<Label>Search</Label>
</NativeTabs.Trigger>
</NativeTabs>
);
}
Rules
- You must include a trigger for each tab
- The
NativeTabs.Trigger'name' must match the route name, including parentheses (e.g.<NativeTabs.Trigger name="(search)">) - Prefer search tab to be last in the list so it can combine with the search bar
- Use the 'role' prop for common tab types
Platform Features
Native Tabs use platform-specific tab bar implementations:
- iOS 26+: Liquid glass effects with system-native appearance
- Android: Material 3 bottom navigation
- Better performance and native feel
Icon Component
// SF Symbol only (iOS)
<Icon sf="house.fill" />
// With Android drawable
<Icon sf="house.fill" drawable="ic_home" />
// Custom image source
<Icon src={require('./icon.png')} />
// State variants (default/selected)
<Icon sf={{ default: "house", selected: "house.fill" }} />
Label Component
// Basic label
<Label>Home</Label>
// Hidden label (icon only)
<Label hidden>Home</Label>
Badge Component
// Numeric badge
<Badge>9+</Badge>
// Dot indicator (empty badge)
<Badge />
iOS 26 Features
Liquid Glass Tab Bar
The tab bar automatically adopts liquid glass appearance on iOS 26+.
Minimize on Scroll
<NativeTabs minimizeBehavior="onScrollDown">
Search Tab
Add a dedicated search tab that integrates with the tab bar search field:
<NativeTabs.Trigger name="(search)" role="search">
<Label>Search</Label>
</NativeTabs.Trigger>
Note: Place search tab last for best UX.
Role Prop
Use semantic roles for special tab types:
<NativeTabs.Trigger name="search" role="search" />
<NativeTabs.Trigger name="favorites" role="favorites" />
<NativeTabs.Trigger name="more" role="more" />
Available roles: search | more | favorites | bookmarks | contacts | downloads | featured | history | mostRecent | mostViewed | recents | topRated
Customization
Tint Color
<NativeTabs tintColor="#007AFF">
Dynamic Colors (iOS)
Use DynamicColorIOS for colors that adapt to liquid glass:
import { DynamicColorIOS, Platform } from 'react-native';
const adaptiveBlue = Platform.select({
ios: DynamicColorIOS({ light: '#007AFF', dark: '#0A84FF' }),
default: '#007AFF',
});
<NativeTabs tintColor={adaptiveBlue}>
Conditional Tabs
Hide tabs conditionally:
<NativeTabs.Trigger name="admin" hidden={!isAdmin}>
<Label>Admin</Label>
<Icon sf="shield.fill" />
</NativeTabs.Trigger>
Behavior Options
<NativeTabs.Trigger
name="home"
disablePopToTop // Don't pop stack when tapping active tab
disableScrollToTop // Don't scroll to top when tapping active tab
>
Using Vector Icons
If you must use @expo/vector-icons instead of SF Symbols:
import { VectorIcon } from "expo-router/unstable-native-tabs";
import Ionicons from "@expo/vector-icons/Ionicons";
<NativeTabs.Trigger name="home">
<VectorIcon vector={Ionicons} name="home" />
<Label>Home</Label>
</NativeTabs.Trigger>;
Prefer SF Symbols over vector icons for native feel on Apple platforms.
Structure with Stacks
Native tabs don't render headers. Nest Stacks inside each tab for navigation headers:
// app/(tabs)/_layout.tsx
import { NativeTabs, Icon, Label } from "expo-router/unstable-native-tabs";
export default function TabLayout() {
return (
<NativeTabs>
<NativeTabs.Trigger name="(home)">
<Label>Home</Label>
<Icon sf="house.fill" />
</NativeTabs.Trigger>
</NativeTabs>
);
}
// app/(tabs)/(home)/_layout.tsx
import Stack from "expo-router/stack";
export default function HomeStack() {
return (
<Stack>
<Stack.Screen
name="index"
options={{ title: "Home", headerLargeTitle: true }}
/>
<Stack.Screen name="details" options={{ title: "Details" }} />
</Stack>
);
}
Migration from JS Tabs
Before (JS Tabs)
import { Tabs } from "expo-router";
export default function TabLayout() {
return (
<Tabs>
<Tabs.Screen
name="index"
options={{
title: "Home",
tabBarIcon: ({ color }) => (
<IconSymbol name="house.fill" color={color} />
),
}}
/>
<Tabs.Screen
name="settings"
options={{
title: "Settings",
tabBarIcon: ({ color }) => <IconSymbol name="gear" color={color} />,
}}
/>
</Tabs>
);
}
After (Native Tabs)
import { NativeTabs, Icon, Label } from "expo-router/unstable-native-tabs";
export default function TabLayout() {
return (
<NativeTabs>
<NativeTabs.Trigger name="index">
<Label>Home</Label>
<Icon sf="house.fill" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="settings">
<Label>Settings</Label>
<Icon sf="gear" />
</NativeTabs.Trigger>
</NativeTabs>
);
}
Key Differences
| JS Tabs | Native Tabs |
|---|---|
<Tabs.Screen> |
<NativeTabs.Trigger> |
options={{ title }} |
<Label>Title</Label> |
options={{ tabBarIcon }} |
<Icon sf="symbol" /> |
| Props-based API | React component-based API |
tabBarBadge option |
<Badge> component |
Migration Steps
-
Change imports
// Remove import { Tabs } from "expo-router"; // Add import { NativeTabs, Icon, Label, Badge, } from "expo-router/unstable-native-tabs"; -
Replace Tabs with NativeTabs
// Before <Tabs screenOptions={{ ... }}> // After <NativeTabs> -
Convert each Screen to Trigger
// Before <Tabs.Screen name="home" options={{ title: 'Home', tabBarIcon: ({ color }) => <Icon name="house" color={color} />, tabBarBadge: 3, }} /> // After <NativeTabs.Trigger name="home"> <Label>Home</Label> <Icon sf="house.fill" /> <Badge>3</Badge> </NativeTabs.Trigger> -
Move headers to nested Stack - Native tabs don't render headers
app/ (tabs)/ _layout.tsx <- NativeTabs (home)/ _layout.tsx <- Stack with headers index.tsx (settings)/ _layout.tsx <- Stack with headers index.tsx
Limitations
- Android: Maximum 5 tabs (Material Design constraint)
- Nesting: Native tabs cannot nest inside other native tabs
- Tab bar height: Cannot be measured programmatically
- FlatList transparency: Use
disableTransparentOnScrollEdgeto fix issues
Keyboard Handling (Android)
Configure in app.json:
{
"expo": {
"android": {
"softwareKeyboardLayoutMode": "resize"
}
}
}
Common Issues
- Icons not showing on Android: Add
drawableprop or useVectorIcon - Headers missing: Nest a Stack inside each tab group
- Trigger name mismatch: Ensure
namematches exact route name including parentheses - Badge not visible: Badge must be a child of Trigger, not a prop