Native tabs

Edit page

Learn how to use the native tabs layout in Expo Router.


Liquid Glass Tabs with Expo Router
Liquid Glass Tabs with Expo Router

Learn how to use native tabs to create liquid glass tabs on iOS with Expo Router.

Native tabs is in alpha and is available in SDK 54 and later. Its API is subject to change.

Tabs are a common way to navigate between different sections of an app. In Expo Router, you can use different tab layouts, depending on your needs. This guide covers the native tabs. Unlike the other tabs layout, native tabs use the native system tab bar.

For other tab layouts see:

Custom tabs

See custom tabs if your app requires a fully custom design that is not possible using system tabs.

JavaScript tabs

See JavaScript tabs if you already use React Navigation's tabs.

Get started

You can use file-based routing to create a tabs layout. Here's an example file structure:

app
 _layout.tsx
 index.tsx
 settings.tsx

The above file structure produces a layout with a tab bar at the bottom of the screen. The tab bar will have two tabs: Home and Settings.

You can use the app/_layout.tsx file to define your app's root layout using tabs. This file is the main layout file for the tab bar and each tab. Inside it, you can control how the tab bar and each tab item look and behave.

app/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs'; export default function TabLayout() { return ( <NativeTabs> <NativeTabs.Trigger name="index"> <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label> <NativeTabs.Trigger.Icon sf="house.fill" md="home" /> </NativeTabs.Trigger> <NativeTabs.Trigger name="settings"> <NativeTabs.Trigger.Icon sf="gear" md="settings" /> <NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label> </NativeTabs.Trigger> </NativeTabs> ); }
app/_layout.tsx
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" drawable="custom_android_drawable" /> </NativeTabs.Trigger> <NativeTabs.Trigger name="settings"> <Icon sf="gear" drawable="custom_settings_drawable" /> <Label>Settings</Label> </NativeTabs.Trigger> </NativeTabs> ); }

Finally, you have the two tab files that make up the content of the tabs: app/index.tsx and app/settings.tsx.

app/index.tsx and app/settings.tsx
import { View, Text, StyleSheet } from 'react-native'; export default function Tab() { return ( <View style={styles.container}> <Text>Tab [Home|Settings]</Text> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', alignItems: 'center', }, });

The tab file named index.tsx is the default tab when the app loads. The second tab file settings.tsx shows how you can add more tabs to the tab bar.

In contrast to the Stack navigator, tabs are not automatically added to the tab bar. You need to explicitly add them in your layout file using the NativeTabs.Trigger.

Customizing tab bar items

When you want to customize the tab bar item, we recommend using the components API designed for this purpose. Currently, you can customize:

  • Icon: The icon displayed in the tab bar item.
  • Label: The label displayed in the tab bar item.
  • Badge: The badge displayed in the tab bar item.

Icon

NativeTabs.Trigger.Icon is available in SDK 55 and later. For SDK 54, use Icon imported from expo-router/unstable-native-tabs.

You can use the Icon component to customize the icon displayed in the tab bar item. The Icon component accepts a md prop for Android material symbols, a sf prop for Apple's SF Symbols icons, or a src prop for custom images.

Alternatively, you can pass {default: ..., selected: ...} to either the sf or src prop to specify different icons for the default and selected states (currently not supported on Android).

app/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs'; export default function TabLayout() { return ( <NativeTabs> <NativeTabs.Trigger name="index"> <NativeTabs.Trigger.Icon sf={{ default: 'house', selected: 'house.fill' }} md="home" /> </NativeTabs.Trigger> <NativeTabs.Trigger name="settings"> <NativeTabs.Trigger.Icon src={require('../../../assets/setting_icon.png')} /> </NativeTabs.Trigger> </NativeTabs> ); }
app/_layout.tsx
import { NativeTabs, Icon } from 'expo-router/unstable-native-tabs'; export default function TabLayout() { return ( <NativeTabs> <NativeTabs.Trigger name="index"> <Icon sf={{ default: 'house', selected: 'house.fill' }} drawable="custom_home_drawable" /> </NativeTabs.Trigger> <NativeTabs.Trigger name="settings"> <Icon src={require('../../../assets/setting_icon.png')} /> </NativeTabs.Trigger> </NativeTabs> ); }

Liquid glass on iOS automatically changes colors based on if the background color is light or dark. There is no callback for this, so you need to use a PlatformColor or DynamicColorIOS to set the color of the icon.

app/_layout.tsx
import { DynamicColorIOS } from 'react-native'; import { NativeTabs } from 'expo-router/unstable-native-tabs'; export default function TabLayout() { return ( <NativeTabs labelStyle={{ // For the text color color: DynamicColorIOS({ dark: 'white', light: 'black', }), }} // For the selected icon color tintColor={DynamicColorIOS({ dark: 'white', light: 'black', })}> <NativeTabs.Trigger name="index"> <NativeTabs.Trigger.Icon sf={{ default: 'house', selected: 'house.fill' }} md="home" /> </NativeTabs.Trigger> <NativeTabs.Trigger name="settings"> <NativeTabs.Trigger.Icon src={{ default: require('../assets/setting_icon.png'), selected: require('../assets/selected_setting_icon.png'), }} /> </NativeTabs.Trigger> </NativeTabs> ); }
app/_layout.tsx
import { DynamicColorIOS } from 'react-native'; import { NativeTabs, Icon } from 'expo-router/unstable-native-tabs'; export default function TabLayout() { return ( <NativeTabs labelStyle={{ // For the text color color: DynamicColorIOS({ dark: 'white', light: 'black', }), }} // For the selected icon color tintColor={DynamicColorIOS({ dark: 'white', light: 'black', })}> <NativeTabs.Trigger name="index"> <Icon sf={{ default: 'house', selected: 'house.fill' }} drawable="custom_home_drawable" /> </NativeTabs.Trigger> <NativeTabs.Trigger name="settings"> <Icon src={{ default: require('../assets/setting_icon.png'), selected: require('../assets/selected_setting_icon.png'), }} /> </NativeTabs.Trigger> </NativeTabs> ); }

Icon rendering mode

Icon rendering mode is available in SDK 55 and later.

When using the src prop for custom images on iOS, you can control how the icon is rendered with the renderingMode prop:

  • template (default): The icon is rendered as a template image, allowing iOS to apply the tint color. This is ideal for single-color icons that should match your app's color scheme.
  • original: The icon is rendered with its original colors preserved. This is useful for icons with gradients or multiple colors.
app/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs'; export default function TabLayout() { return ( <NativeTabs> {/* Icon with original colors preserved (e.g., for gradient or multi-color icons) */} <NativeTabs.Trigger name="colorful"> <NativeTabs.Trigger.Icon src={require('../../../assets/colorful_icon.png')} renderingMode="original" /> </NativeTabs.Trigger> {/* Icon rendered as a template (default behavior) */} <NativeTabs.Trigger name="simple"> <NativeTabs.Trigger.Icon src={require('../../../assets/simple_icon.png')} renderingMode="template" /> </NativeTabs.Trigger> </NativeTabs> ); }
app/_layout.tsx
import { NativeTabs, Icon } from 'expo-router/unstable-native-tabs'; export default function TabLayout() { return ( <NativeTabs> {/* Icon with original colors preserved (e.g., for gradient or multi-color icons) */} <NativeTabs.Trigger name="colorful"> <Icon src={require('../../../assets/colorful_icon.png')} renderingMode="original" /> </NativeTabs.Trigger> {/* Icon rendered as a template (default behavior) */} <NativeTabs.Trigger name="simple"> <Icon src={require('../../../assets/simple_icon.png')} renderingMode="template" /> </NativeTabs.Trigger> </NativeTabs> ); }
The renderingMode prop only affects iOS. On Android, all image icons are rendered with their original colors.

Label

You can use the Label component to customize the label displayed in the tab bar item. The Label component accepts a string label passed as a child. If no label is provided, the tab bar item will use the route name as the label.

If you don't want to display a label, you can use the hidden prop to hide the label.

app/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs'; export default function TabLayout() { return ( <NativeTabs> <NativeTabs.Trigger name="index"> <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label> </NativeTabs.Trigger> <NativeTabs.Trigger name="settings"> <NativeTabs.Trigger.Label hidden /> </NativeTabs.Trigger> </NativeTabs> ); }
app/_layout.tsx
import { NativeTabs, Label } from 'expo-router/unstable-native-tabs'; export default function TabLayout() { return ( <NativeTabs> <NativeTabs.Trigger name="index"> <Label>Home</Label> </NativeTabs.Trigger> <NativeTabs.Trigger name="settings"> <Label hidden /> </NativeTabs.Trigger> </NativeTabs> ); }

Badge

You can use the Badge component to customize the badge displayed for the tab bar item. The badge is an additional mark on top of the tab and useful for showing notification or unread message counts.

app/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs'; export default function TabLayout() { return ( <NativeTabs> <NativeTabs.Trigger name="messages"> <NativeTabs.Trigger.Badge>9+</NativeTabs.Trigger.Badge> </NativeTabs.Trigger> <NativeTabs.Trigger name="settings"> <NativeTabs.Trigger.Badge /> </NativeTabs.Trigger> </NativeTabs> ); }
app/_layout.tsx
import { NativeTabs, Badge } from 'expo-router/unstable-native-tabs'; export default function TabLayout() { return ( <NativeTabs> <NativeTabs.Trigger name="messages"> <Badge>9+</Badge> </NativeTabs.Trigger> <NativeTabs.Trigger name="settings"> <Badge /> </NativeTabs.Trigger> </NativeTabs> ); }

Customizing the tab bar

Since the native tab layout's appearance varies by platform, the customization options are also different. For all customization options, see the API reference for NativeTabs.

Advanced

Hiding the Tab bar

hidden property is available in SDK 55 and later.

You can hide the tab bar using hidden prop on the NativeTabs component. In order to hide tab bar for specific screens, you can use context API to set the hidden prop dynamically.

context/TabBarContext.tsx
import { createContext } from 'react'; export const TabBarContext = createContext<{ setIsTabBarHidden: (hidden: boolean) => void; }>({ setIsTabBarHidden: () => {}, });
app/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs'; import { useState } from 'react'; import { TabBarContext } from '../context/TabBarContext'; export default function TabLayout() { const [isTabBarHidden, setIsTabBarHidden] = useState(false); return ( <TabBarContext value={{ setIsTabBarHidden }}> <NativeTabs hidden={isTabBarHidden}> <NativeTabs.Trigger name="index"> <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label> </NativeTabs.Trigger> <NativeTabs.Trigger name="settings"> <NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label> </NativeTabs.Trigger> </NativeTabs> </TabBarContext> ); }
app/index.tsx
import { useFocusEffect } from 'expo-router'; import { use } from 'react'; import { TabBarContext } from '../context/TabBarContext'; export default function HomeScreen() { const { setIsTabBarHidden } = use(TabBarContext); useFocusEffect(() => { setIsTabBarHidden(true); return () => setIsTabBarHidden(false); }); return ( // Screen content ); }

Hiding a tab conditionally

Dynamically hiding tabs will remount the navigator and the state will be reset. Change the visibility of the tabs only before the navigator is mounted or when it is not visible to the user.

If you want to hide a tab based on a condition, you can either remove the trigger or pass the hidden prop to the NativeTabs.Trigger component.

app/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs'; export default function TabLayout() { const shouldHideMessagesTab = true; // Replace with your condition return ( <NativeTabs> <NativeTabs.Trigger name="messages" hidden={shouldHideMessagesTab} /> </NativeTabs> ); }
Note: Marking a tab as hidden means it cannot be navigated to in any way.

Dismiss behavior

Dismiss behavior is available on Android in SDK 55 and later.

By default, tapping a tab that is already active closes all screens in that tab's stack and returns to the root screen. You can disable this by setting the disablePopToTop prop on the NativeTabs.Trigger component.

app/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs'; export default function TabLayout() { return ( <NativeTabs> <NativeTabs.Trigger name="index" disablePopToTop> <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label> </NativeTabs.Trigger> <NativeTabs.Trigger name="settings"> <NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label> </NativeTabs.Trigger> </NativeTabs> ); }
app/_layout.tsx
import { NativeTabs, Label } from 'expo-router/unstable-native-tabs'; export default function TabLayout() { return ( <NativeTabs> <NativeTabs.Trigger name="index" disablePopToTop> <Label>Home</Label> </NativeTabs.Trigger> <NativeTabs.Trigger name="settings"> <Label>Settings</Label> </NativeTabs.Trigger> </NativeTabs> ); }

Scroll to top

Scroll to top is available on Android in SDK 55 and later.

By default, tapping a tab that is already active and showing its root screen scrolls the content back to the top. You can disable this by setting the disableScrollToTop prop on the NativeTabs.Trigger component.

app/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs'; export default function TabLayout() { return ( <NativeTabs> <NativeTabs.Trigger name="index" disableScrollToTop> <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label> </NativeTabs.Trigger> <NativeTabs.Trigger name="settings"> <NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label> </NativeTabs.Trigger> </NativeTabs> ); }
app/_layout.tsx
import { NativeTabs, Label } from 'expo-router/unstable-native-tabs'; export default function TabLayout() { return ( <NativeTabs> <NativeTabs.Trigger name="index" disableScrollToTop> <Label>Home</Label> </NativeTabs.Trigger> <NativeTabs.Trigger name="settings"> <Label>Settings</Label> </NativeTabs.Trigger> </NativeTabs> ); }

iOS 26 features

To use features described in this section, compile your app with Xcode 26 or higher.

Separate search tab

To add a separate search tab, assign the role with its value set to search to the native tab you want to display separately.

app/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs'; export default function TabLayout() { return ( <NativeTabs> <NativeTabs.Trigger name="index"> <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label> </NativeTabs.Trigger> <NativeTabs.Trigger name="search" role="search"> <NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label> </NativeTabs.Trigger> </NativeTabs> ); }
app/_layout.tsx
import { NativeTabs, Label } from 'expo-router/unstable-native-tabs'; export default function TabLayout() { return ( <NativeTabs> <NativeTabs.Trigger name="index"> <Label>Home</Label> </NativeTabs.Trigger> <NativeTabs.Trigger name="search" role="search"> <Label>Search</Label> </NativeTabs.Trigger> </NativeTabs> ); }

Tabbar search input

To add a search field to the tab bar, wrap the screen in a Stack navigator and configure headerSearchBarOptions.

app
 _layout.tsx
 index.tsx
 search
  _layout.tsx
  index.tsx
app/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs'; export default function TabLayout() { return ( <NativeTabs> <NativeTabs.Trigger name="index"> <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label> </NativeTabs.Trigger> <NativeTabs.Trigger name="search" role="search"> <NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label> </NativeTabs.Trigger> </NativeTabs> ); }
app/search/_layout.tsx
import { Stack } from 'expo-router'; export default function SearchLayout() { return <Stack />; }
app/search/index.tsx
import { ScrollView } from 'react-native'; import { Stack } from 'expo-router'; export default function SearchIndex() { return ( <> <Stack.Screen.Title>Search</Stack.Screen.Title> <Stack.SearchBar placement="automatic" placeholder="Search" onChangeText={() => {}} /> <ScrollView>{/* Screen content */}</ScrollView> </> ); }

Tab bar minimize behavior

To implement the minimized behavior on the tab bar, you can use minimizeBehavior prop on NativeTabs. In the example below, the tab bar is minimized when scrolling down.

app/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs'; export default function TabLayout() { return ( <NativeTabs minimizeBehavior="onScrollDown"> <NativeTabs.Trigger name="index"> <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label> </NativeTabs.Trigger> <NativeTabs.Trigger name="tab-1"> <NativeTabs.Trigger.Label>Tab 1</NativeTabs.Trigger.Label> </NativeTabs.Trigger> </NativeTabs> ); }
app/_layout.tsx
import { NativeTabs, Label } from 'expo-router/unstable-native-tabs'; export default function TabLayout() { return ( <NativeTabs minimizeBehavior="onScrollDown"> <NativeTabs.Trigger name="index"> <Label>Home</Label> </NativeTabs.Trigger> <NativeTabs.Trigger name="tab-1"> <Label>Tab 1</Label> </NativeTabs.Trigger> </NativeTabs> ); }

Disabling keyboard avoidance on Android

By default, when the keyboard is displayed on Android, the native tabs automatically adjust to avoid being obscured. You can disable this behavior by changing the android.softwareKeyboardLayoutMode property to pan in your app config file:

app.json
{ "expo": { "android": { "softwareKeyboardLayoutMode": "pan" } } }

Safe area handling

This feature is available in SDK 55 and later.

Native tabs automatically handle safe area insets, with platform-specific behavior:

  • Android: Screen content is automatically wrapped in a SafeAreaView that applies the bottom inset for the tab bar. Other insets (top, left, right) must be handled manually.
  • iOS: The first ScrollView nested inside a native tabs screen has automatic content inset adjustment enabled. This ensures content scrolls correctly behind the tab bar.

Disabling automatic content insets

If you need full control over safe area handling, you can disable automatic content inset adjustment using the disableAutomaticContentInsets prop on NativeTabs.Trigger:

app/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs'; export default function TabLayout() { return ( <NativeTabs> <NativeTabs.Trigger name="index" disableAutomaticContentInsets> <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label> </NativeTabs.Trigger> </NativeTabs> ); }
app/_layout.tsx
import { NativeTabs, Label } from 'expo-router/unstable-native-tabs'; export default function TabLayout() { return ( <NativeTabs> <NativeTabs.Trigger name="index" disableAutomaticContentInsets> <Label>Home</Label> </NativeTabs.Trigger> </NativeTabs> ); }

When disableAutomaticContentInsets is set to true, you must manage safe area insets manually. You can use SafeAreaView from react-native-screens/experimental:

app/index.tsx
import { SafeAreaView } from 'react-native-screens/experimental'; export default function HomeScreen() { return ( <SafeAreaView edges={{ bottom: true }} style={{ flex: 1 }}> {/* Screen content */} </SafeAreaView> ); }

Migrating from JavaScript tabs

Native tabs are not designed to be a drop-in replacement for JavaScript tabs. The native tabs are constrained to the native platform behavior, whereas the JavaScript tabs can be customized more freely. If you aren't interested in the native platform behavior, you can continue using the JavaScript tabs.

Use Trigger instead of Screen

NativeTabs introduces the concept of a Trigger for adding routes to a layout. Unlike a Screen, which styles routes that are added automatically, the Trigger system gives you better control for hiding and removing tabs from the tab bar.

Use React components instead of props

NativeTabs has a React-first API that opts to use components for defining UI in favor of props objects.

Use Stacks inside tabs

The JavaScript <Tabs /> have a mock stack header which is not present in the native tabs. Instead, you should nest a native <Stack /> layout inside the native tabs to support both headers and pushing screens.

Common problems

The tab bar is transparent on iOS 18 and earlier

On iOS 18 and earlier, the native tab bar becomes transparent when scrolling to the end of a scrollable content. This means that it will become transparent when you scroll to the end of a ScrollView or when you render a static View.

You can use the disableTransparentOnScrollEdge prop to disable this behavior.

app/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs'; function TabLayout() { return ( <NativeTabs> <NativeTabs.Trigger name="index" disableTransparentOnScrollEdge> <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label> </NativeTabs.Trigger> </NativeTabs> ); }

When you are using a ScrollView and the tab bar is transparent from the start, ensure that the ScrollView is a first child of the screen component. If you wrap it with another component make sure to set collapsable to false on the wrapper component.

app/index.tsx
import { ScrollView, View } from 'react-native'; export default function HomeScreen() { return ( <View collapsable={false} style={{ flex: 1 }}> <ScrollView>{/* Screen content */}</ScrollView> </View> ); }

Known limitations

A limit of 5 tabs on Android

On Android, there is a limitation of having a maximum of 5 tabs in the tab bar. This restriction comes from the platform's Material Tabs component.

Cannot measure the tab bar height

The tabs move around, sometimes being on top of the screen when rendering on iPad, sometimes on the side of the screen when running on Apple Vision Pro, and so on. We're working on a layout function to provide more detailed layout info in the future.

No support for nested native tabs

Native tabs cannot be nested inside other native tabs. You can still nest JavaScript tabs inside native tabs.

Limited support for FlatList

FlatList integration with native tabs has limitations. Features like scroll-to-top and minimize-on-scroll aren't supported. Additionally, detecting scroll edges may fail, causing the tab bar to appear transparent. To fix this, use the disableTransparentOnScrollEdge prop.

app/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs'; export default function TabLayout() { return ( <NativeTabs disableTransparentOnScrollEdge> <NativeTabs.Trigger name="index"> <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label> </NativeTabs.Trigger> </NativeTabs> ); }
app/_layout.tsx
import { NativeTabs, Label } from 'expo-router/unstable-native-tabs'; export default function TabLayout() { return ( <NativeTabs disableTransparentOnScrollEdge> <NativeTabs.Trigger name="index"> <Label>Home</Label> </NativeTabs.Trigger> </NativeTabs> ); }
No support for dynamically adding or removing tabs

Dynamically adding or removing tabs at runtime is not supported. Tabs should be defined statically in your layout file and remain consistent throughout the app's lifecycle. This aligns with platform guidelines from Apple's Human Interface Guidelines which recommend keeping tab bar content stable to help users build a mental model of your app's navigation structure. If you dynamically add or remove tabs, the content will be remounted and the state will be lost.