Module 6: Navigation with Expo Router
Tab Navigation
Building parallel navigation with bottom tabs, icons, and badges
π― Learning Objectives
- Set up bottom tab navigation with Expo Router
- Configure tab icons using icon libraries
- Style the tab bar with custom colors and appearance
- Add badges for notifications and unread counts
- Handle tab press events for custom behavior
- Combine tabs with stack navigation for real-world apps
- Implement tab-specific headers and hiding the tab bar
Tab Navigation Basics
Tab navigation provides parallel top-level destinations in your app. Users can switch between tabs freely, and each tab maintains its own navigation state. In Expo Router, you create tabs using a route group with a _layout.tsx file that uses the Tabs component.
app/
βββ _layout.tsx # Root layout (Stack wrapping tabs)
βββ (tabs)/ # Tab group
βββ _layout.tsx # Tab navigator configuration
βββ index.tsx # First tab (Home)
βββ search.tsx # Second tab (Search)
βββ notifications.tsx # Third tab (Notifications)
βββ profile.tsx # Fourth tab (Profile)
Basic Tab Setup
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
export default function TabLayout() {
return (
<Tabs>
<Tabs.Screen
name="index"
options={{
title: 'Home',
}}
/>
<Tabs.Screen
name="search"
options={{
title: 'Search',
}}
/>
<Tabs.Screen
name="notifications"
options={{
title: 'Notifications',
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
}}
/>
</Tabs>
);
}
π Tabs Component
The Tabs component from Expo Router wraps React Navigation's Bottom Tab Navigator. Each Tabs.Screen corresponds to a file in your (tabs) directory. The tab order matches the order of your Tabs.Screen declarations.
Tab Screen Files
// app/(tabs)/index.tsx - Home tab
import { View, Text, StyleSheet } from 'react-native';
export default function HomeScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Home</Text>
<Text style={styles.subtitle}>Welcome to the app!</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
title: {
fontSize: 24,
fontWeight: 'bold',
},
subtitle: {
fontSize: 16,
color: '#666',
marginTop: 8,
},
});
// app/(tabs)/search.tsx - Search tab
export default function SearchScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Search</Text>
</View>
);
}
// Similar structure for other tabs...
Tab Icons
Icons are essential for tab navigationβthey help users quickly identify each tab. Expo provides the @expo/vector-icons package with popular icon libraries built in.
Using Ionicons
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
export default function TabLayout() {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: '#2196F3',
tabBarInactiveTintColor: '#9e9e9e',
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color, size }) => (
<Ionicons name="home" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="search"
options={{
title: 'Search',
tabBarIcon: ({ color, size }) => (
<Ionicons name="search" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="notifications"
options={{
title: 'Notifications',
tabBarIcon: ({ color, size }) => (
<Ionicons name="notifications" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ color, size }) => (
<Ionicons name="person" size={size} color={color} />
),
}}
/>
</Tabs>
);
}
Focused vs Unfocused Icons
Many icon libraries have filled and outline variants. Use them to distinguish active tabs:
// Using different icons for focused/unfocused state
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color, size, focused }) => (
<Ionicons
name={focused ? 'home' : 'home-outline'}
size={size}
color={color}
/>
),
}}
/>
<Tabs.Screen
name="notifications"
options={{
title: 'Notifications',
tabBarIcon: ({ color, size, focused }) => (
<Ionicons
name={focused ? 'notifications' : 'notifications-outline'}
size={size}
color={color}
/>
),
}}
/>
Available Icon Libraries
π¦ @expo/vector-icons Libraries
| Library | Import | Style |
|---|---|---|
| Ionicons | import { Ionicons } from '@expo/vector-icons' |
iOS-style, outline/filled |
| MaterialIcons | import { MaterialIcons } from '@expo/vector-icons' |
Material Design |
| MaterialCommunityIcons | import { MaterialCommunityIcons } from '@expo/vector-icons' |
Extended Material |
| FontAwesome | import { FontAwesome } from '@expo/vector-icons' |
Classic web icons |
| Feather | import { Feather } from '@expo/vector-icons' |
Minimal, line icons |
π‘ Finding Icon Names
Browse available icons at icons.expo.fyi. Search by name, copy the import statement, and use it in your code.
Custom Tab Icons
// Using custom images or SVGs
import { Image } from 'react-native';
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ focused }) => (
<Image
source={
focused
? require('@/assets/icons/home-active.png')
: require('@/assets/icons/home.png')
}
style={{ width: 24, height: 24 }}
/>
),
}}
/>
// Using a custom SVG component
import HomeSvg from '@/assets/icons/home.svg';
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color, size }) => (
<HomeSvg width={size} height={size} fill={color} />
),
}}
/>
Tab Bar Styling
Customize the tab bar appearance to match your brand and design system.
Common Style Options
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import { Platform } from 'react-native';
export default function TabLayout() {
return (
<Tabs
screenOptions={{
// Tab bar colors
tabBarActiveTintColor: '#6366f1',
tabBarInactiveTintColor: '#9ca3af',
// Tab bar background
tabBarStyle: {
backgroundColor: '#ffffff',
borderTopWidth: 1,
borderTopColor: '#e5e7eb',
height: Platform.OS === 'ios' ? 88 : 60,
paddingBottom: Platform.OS === 'ios' ? 28 : 8,
paddingTop: 8,
},
// Tab label styling
tabBarLabelStyle: {
fontSize: 12,
fontWeight: '500',
},
// Show/hide labels
tabBarShowLabel: true,
// Header styling (applies to all tab screens)
headerStyle: {
backgroundColor: '#6366f1',
},
headerTintColor: '#fff',
headerTitleStyle: {
fontWeight: 'bold',
},
}}
>
{/* Tabs.Screen components */}
</Tabs>
);
}
Dark Mode Support
import { Tabs } from 'expo-router';
import { useColorScheme } from 'react-native';
export default function TabLayout() {
const colorScheme = useColorScheme();
const isDark = colorScheme === 'dark';
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: isDark ? '#818cf8' : '#6366f1',
tabBarInactiveTintColor: isDark ? '#6b7280' : '#9ca3af',
tabBarStyle: {
backgroundColor: isDark ? '#1f2937' : '#ffffff',
borderTopColor: isDark ? '#374151' : '#e5e7eb',
},
headerStyle: {
backgroundColor: isDark ? '#1f2937' : '#6366f1',
},
headerTintColor: '#fff',
}}
>
{/* Tabs */}
</Tabs>
);
}
Platform-Specific Styling
import { Platform } from 'react-native';
const tabBarStyle = Platform.select({
ios: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
position: 'absolute', // Floating tab bar
borderTopWidth: 0,
elevation: 0,
shadowColor: '#000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.1,
shadowRadius: 8,
},
android: {
backgroundColor: '#ffffff',
borderTopWidth: 1,
borderTopColor: '#e5e7eb',
elevation: 8,
},
});
// In screenOptions
tabBarStyle: tabBarStyle,
Custom Tab Bar Background
import { BlurView } from 'expo-blur';
<Tabs
screenOptions={{
tabBarBackground: () => (
<BlurView
intensity={100}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
/>
),
tabBarStyle: {
position: 'absolute',
backgroundColor: 'transparent',
borderTopWidth: 0,
},
}}
>
Badges and Indicators
Badges show notification counts or status indicators on tab icons. They're essential for drawing attention to updates.
Simple Badge
// app/(tabs)/_layout.tsx
<Tabs.Screen
name="notifications"
options={{
title: 'Notifications',
tabBarIcon: ({ color, size }) => (
<Ionicons name="notifications-outline" size={size} color={color} />
),
// Simple badge with count
tabBarBadge: 5,
}}
/>
// Dynamic badge based on state
import { useNotifications } from '@/hooks/useNotifications';
export default function TabLayout() {
const { unreadCount } = useNotifications();
return (
<Tabs>
<Tabs.Screen
name="notifications"
options={{
tabBarBadge: unreadCount > 0 ? unreadCount : undefined,
}}
/>
</Tabs>
);
}
Styled Badge
// Badge styling
<Tabs
screenOptions={{
tabBarBadgeStyle: {
backgroundColor: '#ef4444',
color: '#fff',
fontSize: 10,
fontWeight: 'bold',
minWidth: 18,
height: 18,
borderRadius: 9,
},
}}
>
Custom Badge Component
// components/TabBarIconWithBadge.tsx
import { View, Text, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
interface TabBarIconWithBadgeProps {
name: keyof typeof Ionicons.glyphMap;
color: string;
size: number;
badge?: number | boolean;
}
export function TabBarIconWithBadge({
name,
color,
size,
badge
}: TabBarIconWithBadgeProps) {
return (
<View style={styles.container}>
<Ionicons name={name} size={size} color={color} />
{badge !== undefined && badge !== false && badge !== 0 && (
<View style={styles.badge}>
{typeof badge === 'number' && (
<Text style={styles.badgeText}>
{badge > 99 ? '99+' : badge}
</Text>
)}
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
width: 28,
height: 28,
justifyContent: 'center',
alignItems: 'center',
},
badge: {
position: 'absolute',
top: -4,
right: -8,
backgroundColor: '#ef4444',
borderRadius: 10,
minWidth: 18,
height: 18,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 4,
},
badgeText: {
color: '#fff',
fontSize: 10,
fontWeight: 'bold',
},
});
// Usage
<Tabs.Screen
name="notifications"
options={{
tabBarIcon: ({ color, size }) => (
<TabBarIconWithBadge
name="notifications-outline"
color={color}
size={size}
badge={unreadCount}
/>
),
}}
/>
Dot Indicator (No Number)
// Show a dot for "has updates" without a count
function TabBarIconWithDot({ name, color, size, showDot }) {
return (
<View style={styles.container}>
<Ionicons name={name} size={size} color={color} />
{showDot && <View style={styles.dot} />}
</View>
);
}
const styles = StyleSheet.create({
container: {
width: 28,
height: 28,
justifyContent: 'center',
alignItems: 'center',
},
dot: {
position: 'absolute',
top: 0,
right: 0,
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: '#ef4444',
},
});
Tab Press Events
Handle tab press events for custom behavior like scrolling to top, refreshing data, or showing modals.
Tab Press Listeners
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
export default function TabLayout() {
return (
<Tabs
screenListeners={{
tabPress: (e) => {
// Runs for every tab press
console.log('Tab pressed:', e.target);
},
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Home',
}}
listeners={{
tabPress: (e) => {
// Only runs for this specific tab
console.log('Home tab pressed');
},
}}
/>
</Tabs>
);
}
Scroll to Top on Tab Press
// app/(tabs)/index.tsx
import { useRef, useCallback } from 'react';
import { FlatList, View, Text } from 'react-native';
import { useFocusEffect, useNavigation } from '@react-navigation/native';
export default function HomeScreen() {
const flatListRef = useRef<FlatList>(null);
const navigation = useNavigation();
useFocusEffect(
useCallback(() => {
// Add listener for when this tab is pressed while already focused
const unsubscribe = navigation.addListener('tabPress', (e) => {
// Scroll to top
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
});
return unsubscribe;
}, [navigation])
);
return (
<FlatList
ref={flatListRef}
data={items}
renderItem={({ item }) => <Text>{item.title}</Text>}
/>
);
}
Prevent Tab Switch
// Prevent switching to a tab (e.g., require login)
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
}}
listeners={{
tabPress: (e) => {
if (!isLoggedIn) {
// Prevent the default action
e.preventDefault();
// Navigate to login instead
router.push('/login');
}
},
}}
/>
Long Press on Tab
// Handle long press for additional options
<Tabs.Screen
name="notifications"
listeners={{
tabLongPress: (e) => {
// Show action sheet or modal
Alert.alert(
'Notifications',
'What would you like to do?',
[
{ text: 'Mark All as Read', onPress: markAllRead },
{ text: 'Settings', onPress: () => router.push('/notification-settings') },
{ text: 'Cancel', style: 'cancel' },
]
);
},
}}
/>
flowchart TD
A[User taps tab] --> B{Already on this tab?}
B -->|No| C[Switch to tab]
B -->|Yes| D{Has tabPress listener?}
D -->|No| E[Default behavior]
D -->|Yes| F[Run listener]
F --> G{preventDefault called?}
G -->|No| H[Continue with navigation]
G -->|Yes| I[Cancel navigation]
H --> J[Screen focuses]
E --> K[Scroll to top if scrollable]
style C fill:#e8f5e9
style J fill:#e8f5e9
style I fill:#fff3cd
Combining Tabs with Stacks
Real-world apps combine tabs with stacks. Each tab typically has its own stack navigator for drilling into content while maintaining the tab bar.
Architecture Overview
flowchart TD
subgraph Root["Root Stack"]
A[Root Layout]
end
subgraph TabNav["Tab Navigator"]
B["(tabs)/_layout.tsx"]
subgraph Tab1["Home Tab"]
C[index.tsx]
D[product/id.tsx]
E[category/id.tsx]
end
subgraph Tab2["Search Tab"]
F[search/index.tsx]
G[search/results.tsx]
end
subgraph Tab3["Profile Tab"]
H[profile/index.tsx]
I[profile/settings.tsx]
J[profile/edit.tsx]
end
end
subgraph Modals["Modal Screens"]
K[login.tsx]
L[create-post.tsx]
end
A --> B
B --> Tab1
B --> Tab2
B --> Tab3
A --> Modals
style A fill:#e3f2fd
style B fill:#fff3cd
style K fill:#fce4ec
style L fill:#fce4ec
File Structure for Tabs + Stacks
app/
βββ _layout.tsx # Root Stack
βββ login.tsx # Modal (outside tabs)
βββ create-post.tsx # Modal (outside tabs)
βββ (tabs)/
βββ _layout.tsx # Tab Navigator
βββ index.tsx # Home tab entry
βββ home/ # Home tab stack screens
β βββ _layout.tsx # Home stack navigator
β βββ index.tsx # Home main screen
β βββ [productId].tsx # Product detail
β βββ category/
β βββ [id].tsx # Category screen
βββ search.tsx # Search tab (single screen)
βββ notifications.tsx # Notifications tab
βββ profile/ # Profile tab stack screens
βββ _layout.tsx # Profile stack navigator
βββ index.tsx # Profile main
βββ settings.tsx # Settings screen
βββ edit.tsx # Edit profile
Root Layout with Tabs and Modals
// app/_layout.tsx
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<Stack>
{/* Tab navigator as main content */}
<Stack.Screen
name="(tabs)"
options={{ headerShown: false }}
/>
{/* Modal screens accessible from anywhere */}
<Stack.Screen
name="login"
options={{
presentation: 'modal',
title: 'Login',
}}
/>
<Stack.Screen
name="create-post"
options={{
presentation: 'modal',
title: 'Create Post',
}}
/>
</Stack>
);
}
Tab Layout with Nested Stacks
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
export default function TabLayout() {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: '#6366f1',
headerShown: false, // Each stack has its own headers
}}
>
<Tabs.Screen
name="home"
options={{
title: 'Home',
tabBarIcon: ({ color, size, focused }) => (
<Ionicons
name={focused ? 'home' : 'home-outline'}
size={size}
color={color}
/>
),
}}
/>
<Tabs.Screen
name="search"
options={{
title: 'Search',
headerShown: true, // Single screen, show header
tabBarIcon: ({ color, size, focused }) => (
<Ionicons
name={focused ? 'search' : 'search-outline'}
size={size}
color={color}
/>
),
}}
/>
<Tabs.Screen
name="notifications"
options={{
title: 'Notifications',
headerShown: true,
tabBarIcon: ({ color, size, focused }) => (
<Ionicons
name={focused ? 'notifications' : 'notifications-outline'}
size={size}
color={color}
/>
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ color, size, focused }) => (
<Ionicons
name={focused ? 'person' : 'person-outline'}
size={size}
color={color}
/>
),
}}
/>
</Tabs>
);
}
Nested Stack Layout
// app/(tabs)/home/_layout.tsx
import { Stack } from 'expo-router';
export default function HomeLayout() {
return (
<Stack
screenOptions={{
headerStyle: { backgroundColor: '#6366f1' },
headerTintColor: '#fff',
}}
>
<Stack.Screen
name="index"
options={{ title: 'Home' }}
/>
<Stack.Screen
name="[productId]"
options={{ title: 'Product' }}
/>
<Stack.Screen
name="category/[id]"
options={{ title: 'Category' }}
/>
</Stack>
);
}
// app/(tabs)/home/index.tsx
import { View, FlatList, Pressable, Text, StyleSheet } from 'react-native';
import { Link } from 'expo-router';
export default function HomeScreen() {
const products = [
{ id: '1', name: 'Product 1' },
{ id: '2', name: 'Product 2' },
];
return (
<FlatList
data={products}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<Link href={`/home/${item.id}`} asChild>
<Pressable style={styles.item}>
<Text>{item.name}</Text>
</Pressable>
</Link>
)}
/>
);
}
// app/(tabs)/home/[productId].tsx
import { useLocalSearchParams, Stack } from 'expo-router';
import { View, Text } from 'react-native';
export default function ProductScreen() {
const { productId } = useLocalSearchParams();
return (
<>
<Stack.Screen options={{ title: `Product ${productId}` }} />
<View>
<Text>Product Details: {productId}</Text>
</View>
</>
);
}
β Navigation Tips
- Use
headerShown: falseon tabs when each tab has its own stack with headers - Keep modal screens at the root level so they can be accessed from any tab
- Each tab's stack maintains its own navigation history
- Use absolute paths like
/home/123for cross-tab navigation
Hiding the Tab Bar
Sometimes you need to hide the tab bar on certain screensβlike detail views, media players, or immersive experiences.
Method 1: Per-Screen Option
// In the screen file itself
import { Tabs } from 'expo-router';
export default function FullScreenVideo() {
return (
<>
<Tabs.Screen
options={{
tabBarStyle: { display: 'none' },
// Also hide header if needed
headerShown: false,
}}
/>
<VideoPlayer />
</>
);
}
Method 2: In the Stack Layout
// app/(tabs)/home/_layout.tsx
import { Stack } from 'expo-router';
export default function HomeLayout() {
return (
<Stack>
<Stack.Screen name="index" options={{ title: 'Home' }} />
{/* Hide tab bar for this screen */}
<Stack.Screen
name="video/[id]"
options={{
title: 'Video',
// This screen gets full height
}}
/>
</Stack>
);
}
// Then in the video screen:
// app/(tabs)/home/video/[id].tsx
import { useNavigation } from '@react-navigation/native';
import { useLayoutEffect } from 'react';
export default function VideoScreen() {
const navigation = useNavigation();
const parentNavigation = navigation.getParent();
useLayoutEffect(() => {
// Hide tab bar when this screen mounts
parentNavigation?.setOptions({
tabBarStyle: { display: 'none' },
});
return () => {
// Show tab bar when leaving
parentNavigation?.setOptions({
tabBarStyle: undefined,
});
};
}, [parentNavigation]);
return <VideoPlayer />;
}
Method 3: Custom Hook for Hiding Tab Bar
// hooks/useHideTabBar.ts
import { useNavigation } from '@react-navigation/native';
import { useLayoutEffect } from 'react';
export function useHideTabBar() {
const navigation = useNavigation();
useLayoutEffect(() => {
const parent = navigation.getParent();
parent?.setOptions({
tabBarStyle: { display: 'none' },
});
return () => {
parent?.setOptions({
tabBarStyle: undefined,
});
};
}, [navigation]);
}
// Usage in any screen
export default function ImmersiveScreen() {
useHideTabBar();
return <View>{/* Full screen content */}</View>;
}
Animated Tab Bar Hiding
// For smoother transitions, animate the tab bar
import Animated, {
useAnimatedStyle,
withTiming,
useSharedValue,
} from 'react-native-reanimated';
// In your tab layout, use Animated tab bar style
const tabBarTranslateY = useSharedValue(0);
const animatedTabBarStyle = useAnimatedStyle(() => ({
transform: [{ translateY: tabBarTranslateY.value }],
}));
// Expose a method to hide/show
const hideTabBar = () => {
tabBarTranslateY.value = withTiming(100); // Slide down
};
const showTabBar = () => {
tabBarTranslateY.value = withTiming(0); // Slide up
};
β οΈ Tab Bar Hiding Gotchas
- Hiding the tab bar can cause layout jumpsβconsider animating it
- Remember to show the tab bar again when leaving the screen
- Test on both iOS and Android as behavior may differ
- For truly immersive screens, consider putting them outside the tab navigator entirely
Hands-On Exercises
Exercise 1: Basic Tab Setup with Icons
Create a 4-tab application with:
- Home, Explore, Messages, Profile tabs
- Filled icons for active state, outline for inactive
- Custom active/inactive colors
- Each tab shows its name as a title
β Solution
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
export default function TabLayout() {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: '#6366f1',
tabBarInactiveTintColor: '#9ca3af',
tabBarStyle: {
backgroundColor: '#fff',
borderTopColor: '#e5e7eb',
},
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color, size, focused }) => (
<Ionicons
name={focused ? 'home' : 'home-outline'}
size={size}
color={color}
/>
),
}}
/>
<Tabs.Screen
name="explore"
options={{
title: 'Explore',
tabBarIcon: ({ color, size, focused }) => (
<Ionicons
name={focused ? 'compass' : 'compass-outline'}
size={size}
color={color}
/>
),
}}
/>
<Tabs.Screen
name="messages"
options={{
title: 'Messages',
tabBarIcon: ({ color, size, focused }) => (
<Ionicons
name={focused ? 'chatbubbles' : 'chatbubbles-outline'}
size={size}
color={color}
/>
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ color, size, focused }) => (
<Ionicons
name={focused ? 'person' : 'person-outline'}
size={size}
color={color}
/>
),
}}
/>
</Tabs>
);
}
Exercise 2: Dynamic Badge System
Implement a notification badge that:
- Shows a count on the Messages tab
- Updates dynamically (simulate with useState)
- Shows "99+" for counts over 99
- Disappears when count is 0
β Solution
// context/NotificationContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react';
interface NotificationContextType {
unreadCount: number;
setUnreadCount: (count: number) => void;
markAsRead: () => void;
addNotification: () => void;
}
const NotificationContext = createContext<NotificationContextType | null>(null);
export function NotificationProvider({ children }: { children: ReactNode }) {
const [unreadCount, setUnreadCount] = useState(5);
const markAsRead = () => setUnreadCount(0);
const addNotification = () => setUnreadCount(prev => prev + 1);
return (
<NotificationContext.Provider
value={{ unreadCount, setUnreadCount, markAsRead, addNotification }}
>
{children}
</NotificationContext.Provider>
);
}
export const useNotifications = () => {
const context = useContext(NotificationContext);
if (!context) throw new Error('useNotifications must be used within provider');
return context;
};
// app/(tabs)/_layout.tsx
import { useNotifications } from '@/context/NotificationContext';
export default function TabLayout() {
const { unreadCount } = useNotifications();
const formatBadge = (count: number) => {
if (count === 0) return undefined;
if (count > 99) return '99+';
return count;
};
return (
<Tabs>
<Tabs.Screen
name="messages"
options={{
title: 'Messages',
tabBarBadge: formatBadge(unreadCount),
tabBarBadgeStyle: {
backgroundColor: '#ef4444',
fontSize: 10,
},
tabBarIcon: ({ color, size, focused }) => (
<Ionicons
name={focused ? 'chatbubbles' : 'chatbubbles-outline'}
size={size}
color={color}
/>
),
}}
/>
</Tabs>
);
}
Exercise 3: Tabs with Nested Stacks
Build a shopping app structure:
- Shop tab with product list β product detail β reviews
- Cart tab with cart β checkout flow
- Account tab with profile β settings β edit profile
- Tab bar should remain visible during stack navigation
π‘ Hint
Create folders for each tab that needs a stack: (tabs)/shop/, (tabs)/cart/, (tabs)/account/. Each folder needs a _layout.tsx with a Stack navigator. Set headerShown: false on the Tabs.Screen for these tabs.
β Solution Structure
app/
βββ _layout.tsx
βββ (tabs)/
βββ _layout.tsx
βββ shop/
β βββ _layout.tsx # Stack
β βββ index.tsx # Product list
β βββ [id].tsx # Product detail
β βββ [id]/reviews.tsx # Reviews
βββ cart/
β βββ _layout.tsx # Stack
β βββ index.tsx # Cart
β βββ checkout.tsx # Checkout
βββ account/
βββ _layout.tsx # Stack
βββ index.tsx # Profile
βββ settings.tsx # Settings
βββ edit.tsx # Edit profile
// app/(tabs)/_layout.tsx
<Tabs screenOptions={{ headerShown: false }}>
<Tabs.Screen name="shop" options={{ title: 'Shop' }} />
<Tabs.Screen name="cart" options={{ title: 'Cart' }} />
<Tabs.Screen name="account" options={{ title: 'Account' }} />
</Tabs>
// app/(tabs)/shop/_layout.tsx
import { Stack } from 'expo-router';
export default function ShopLayout() {
return (
<Stack>
<Stack.Screen name="index" options={{ title: 'Shop' }} />
<Stack.Screen name="[id]" options={{ title: 'Product' }} />
<Stack.Screen name="[id]/reviews" options={{ title: 'Reviews' }} />
</Stack>
);
}
Exercise 4: Scroll to Top on Tab Re-press
Implement Instagram-like behavior where tapping the current tab scrolls the list to top.
β Solution
// app/(tabs)/feed.tsx
import { useRef, useCallback } from 'react';
import { FlatList, View, Text, StyleSheet } from 'react-native';
import { useFocusEffect, useNavigation } from '@react-navigation/native';
const posts = Array.from({ length: 50 }, (_, i) => ({
id: String(i),
title: `Post ${i + 1}`,
}));
export default function FeedScreen() {
const flatListRef = useRef<FlatList>(null);
const navigation = useNavigation();
useFocusEffect(
useCallback(() => {
const unsubscribe = navigation.addListener('tabPress', (e) => {
// Check if we're already on this tab
const isFocused = navigation.isFocused();
if (isFocused) {
// Scroll to top with animation
flatListRef.current?.scrollToOffset({
offset: 0,
animated: true,
});
}
});
return unsubscribe;
}, [navigation])
);
return (
<FlatList
ref={flatListRef}
data={posts}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={styles.post}>
<Text style={styles.postTitle}>{item.title}</Text>
</View>
)}
/>
);
}
const styles = StyleSheet.create({
post: {
padding: 20,
borderBottomWidth: 1,
borderBottomColor: '#eee',
},
postTitle: {
fontSize: 16,
},
});
Summary
You've mastered tab navigationβthe primary navigation pattern for most mobile apps. You can now create polished tab bars with icons, badges, and custom behavior.
π― Key Takeaways
- Tab Groups: Use
(tabs)/folder withTabscomponent in_layout.tsx - Icons: Use
tabBarIconwith focused/unfocused variants for clear visual feedback - Styling: Customize with
tabBarStyle,tabBarActiveTintColor, and platform-specific options - Badges: Use
tabBarBadgefor counts or custom components for complex indicators - Events: Handle
tabPressfor scroll-to-top, refresh, or custom behavior - Nested Stacks: Create folders within tabs for multi-screen flows while keeping tab bar visible
- Hiding Tab Bar: Use
tabBarStyle: { display: 'none' }for immersive screens
π± Common Tab Configurations
// Social App: Home, Search, Create, Notifications, Profile
// E-commerce: Home, Categories, Cart, Account
// Media: Home, Search, Library, Downloads
// Productivity: Inbox, Today, Projects, Settings
// Tips:
// - 3-5 tabs is ideal
// - Most important tabs on the left
// - Profile/Settings typically on the right
// - Use badges sparingly (notifications, cart count)
What's Next?
In the next lesson, we'll explore Nested Navigation in depthβcombining multiple navigators for complex app architectures, handling deep navigation states, and implementing common patterns like authentication flows.