Skip to main content

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 Bar Anatomy 🏠 Home πŸ” Search πŸ”” 5 Alerts πŸ‘€ Profile Active Tab Badge tabBarStyle, tabBarActiveTintColor, tabBarInactiveTintColor

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,
Tab Bar Style Variations Default 🏠 πŸ” πŸ”” πŸ‘€ 🏠 Minimal (no labels) 🏠 πŸ” πŸ”” πŸ‘€ Floating (iOS style) 🏠 πŸ” πŸ”” πŸ‘€ Border top, labels No border, icons only Rounded, elevated tabBarShowLabel: true tabBarShowLabel: false position: 'absolute'

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: false on 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/123 for 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 with Tabs component in _layout.tsx
  • Icons: Use tabBarIcon with focused/unfocused variants for clear visual feedback
  • Styling: Customize with tabBarStyle, tabBarActiveTintColor, and platform-specific options
  • Badges: Use tabBarBadge for counts or custom components for complex indicators
  • Events: Handle tabPress for 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.