Tabs
A tab layout with icons, labels and badges, and why each tab still has its state when you switch back to it.
<Tabs.Screen name="index" options={{ title, tabBarIcon, tabBarBadge }} />Tabs are a fixed set of sections the user switches between. They are not a stack: switching does not pile screens up, and there is no back from one tab to another. In Expo Router a tab bar is a layout, usually app/(tabs)/_layout.tsx, exporting Tabs. The folder name is in parentheses so the tabs add nothing to the path, and the index route is the tab selected first.
Labels, icons, badges
title on a Tabs.Screen sets both the tab label and the screen header; tabBarLabel overrides the label alone. tabBarIcon is a function called with an object of focused, color and size that returns an element — you pick the icon, the navigator picks the colour from tabBarActiveTintColor and tabBarInactiveTintColor. tabBarBadge takes a string or a number. href: null keeps a route inside the group but off the bar.
State survives a switch
lazy defaults to true, so a tab's screen mounts the first time it is visited. After that it stays mounted. That is why scroll position, text in an input and useState values are all still there when you come back. If a tab's nested stack should reset when the user leaves it, set popToTopOnBlur to true on that screen.
tabBarStyle is a style object for one view, not a selector, and nothing on it cascades into the tab screens.
Example
// app/(tabs)/_layout.tsx
// On screen: a three-item bar at the bottom of the display - Feed, Alerts and
// Settings - with the active item's icon and label tinted teal, and a small "3"
// badge on the Alerts icon.
import Ionicons from '@expo/vector-icons/Ionicons';
import { Tabs } from 'expo-router';
export default function TabLayout() {
return (
<Tabs screenOptions={{ tabBarActiveTintColor: '#0a7ea4' }}>
{/* index is the tab selected on launch */}
<Tabs.Screen
name="index"
options={{
title: 'Feed', // used for both the tab label and the header
// tabBarIcon is called with { focused, color, size }. Destructuring in
// the parameter list is the short form of (props) => ... props.color.
tabBarIcon: ({ color, size }) => <Ionicons name="list" size={size} color={color} />,
}}
/>
<Tabs.Screen
name="alerts"
options={{
title: 'Alerts',
tabBarBadge: 3, // a string or a number
tabBarIcon: ({ color, size }) => (
<Ionicons name="notifications" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="settings"
options={{
title: 'Settings',
// focused lets the icon change shape, not just colour
tabBarIcon: ({ color, size, focused }) => (
<Ionicons name={focused ? 'settings' : 'settings-outline'} size={size} color={color} />
),
}}
/>
</Tabs>
);
}When to use it
- Building the bottom bar of an app with three or four top-level sections.
- Showing an unread count on one tab's icon.
- Deciding whether a tab should resume where the user left it or start at the top.
More examples
Keeping the stack above the tabs out of the way
The double header is the first bug nearly everyone hits after adding tabs under a stack. This is the fix, and it also shows where a full-screen detail route belongs: outside the group, so the tab bar does not stay on top of it.
// app/_layout.tsx
// The (tabs) group has its own layout, which draws its own headers. Without
// headerShown: false the app shows two stacked headers on every tab.
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
{/* Declared outside the group, so it covers the tab bar when pushed */}
<Stack.Screen name="order/[id]" options={{ title: 'Order' }} />
</Stack>
);
}Proving that a tab keeps its state
Useful when a tab needs to react to being shown again. Because the mount effect will not fire a second time, anything that must run on every visit needs a focus-based trigger rather than an empty dependency array.
// app/(tabs)/index.tsx
// On screen: a teal button reading "Tapped 0 times" above a long list. Switch to
// another tab and back: the count and the scroll position are both unchanged,
// because the screen was never unmounted.
import { useEffect, useState } from 'react';
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
const ROWS = Array.from({ length: 200 }, (_, index) => ({
id: String(index),
label: `Row ${index}`,
}));
export default function Feed() {
const [taps, setTaps] = useState(0);
useEffect(() => {
// Runs on the first visit to this tab only. Switching tabs does not
// unmount the screen, so this will not run a second time.
console.log('feed mounted');
}, []);
return (
<View style={styles.container}>
<Pressable style={styles.button} onPress={() => setTaps(taps + 1)}>
<Text style={styles.buttonText}>Tapped {taps} times</Text>
</Pressable>
<FlatList
data={ROWS}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <Text style={styles.row}>{item.label}</Text>}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
button: { padding: 16, backgroundColor: '#0a7ea4' },
buttonText: { color: '#fff', fontSize: 16 },
row: { padding: 16, fontSize: 16 },
});A route in the group that is not a tab
Two options worth knowing early. href: null stops an extra tab appearing the moment you add a file to the group, and popToTopOnBlur decides whether a tab remembers how deep the user had gone.
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
export default function TabLayout() {
return (
<Tabs>
<Tabs.Screen name="index" options={{ title: 'Feed' }} />
{/* href: null keeps the route reachable by link while removing it
from the bar. Use it for a screen that belongs to the tabs group
but should not be one of the buttons. */}
<Tabs.Screen name="compose" options={{ href: null }} />
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
// Leaving this tab resets its nested stack to its first screen.
// Defaults to false, which keeps the user where they were.
popToTopOnBlur: true,
}}
/>
</Tabs>
);
}
Discussion