Tabs

A tab layout with icons, labels and badges, and why each tab still has its state when you switch back to it.

Syntax<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

Example · tsx
// 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.

Example · tsx
// 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.

Example · tsx
// 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.

Example · tsx
// 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

  • Be the first to comment on this lesson.