Native tabs
Learn how to use the native tabs layout in 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:
src
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 src/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.
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>
);
}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.Iconis available in SDK 55 and later. For SDK 54, useIconimported fromexpo-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 the sf, xcasset, drawable, md, or src prop to specify different icons for the default and selected states.
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={{ default: 'home', selected: 'home_filled' }}
/>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="settings">
<NativeTabs.Trigger.Icon src={require('../../../assets/setting_icon.png')} />
</NativeTabs.Trigger>
</NativeTabs>
);
}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>
);
}Icon rendering mode
Icon rendering mode is available in SDK 55 and later.
When using the src or xcasset 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.
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>
);
}Asset catalog icons (iOS)
This feature is available in SDK 55 and later.
On iOS, you can use images from the Xcode asset catalog as tab icons with the xcasset prop. This is useful when you want to manage your icons through Xcode's asset catalog instead of bundling image files.
Pass a string with the asset name to use the same icon for both default and selected states:
import { NativeTabs } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
return (
<NativeTabs>
<NativeTabs.Trigger name="index">
<NativeTabs.Trigger.Icon xcasset="home-icon" />
</NativeTabs.Trigger>
</NativeTabs>
);
}To use different icons for default and selected states, pass an object:
import { NativeTabs } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
return (
<NativeTabs>
<NativeTabs.Trigger name="index">
<NativeTabs.Trigger.Icon
xcasset={{
default: 'home-outline',
selected: 'home-filled',
}}
/>
</NativeTabs.Trigger>
</NativeTabs>
);
}Asset catalog icons support the
renderingModeprop, just likesrcicons. WheniconColoris set, icons default totemplaterendering. Otherwise, they default tooriginal.
Vector icons
You can render icons from an icon font, such as the ones provided by react-native-vector-icons, by passing an image source to the src prop. Each icon set exposes a getImageSourceSync method that rasterizes a glyph to an image source you can pass directly to src.
This is useful on Android, where the built-in md prop only renders outlined Material Symbols. An icon font like Material Design Icons provides both outlined and filled glyphs (for example, home-outline and home), so you can show distinct default and selected icons.
To start, install the icon set you want to use along with @react-native-vector-icons/get-image, which provides the native module that getImageSourceSync relies on. The example below uses the @react-native-vector-icons/material-design-icons icon set:
bun expo install @react-native-vector-icons/material-design-icons @react-native-vector-icons/get-image
getImageSourceSyncrequires a development build. Rebuild your app after installing the packages so the native module and icon font are bundled.
getImageSourceSync is synchronous, so compute the image sources once at module scope rather than on each render. Combine src with sf to use the vector icon on Android and SF Symbols on iOS. On iOS, sf takes precedence over src; on Android, the icon falls back to src.
import MaterialDesignIcons from '@react-native-vector-icons/material-design-icons';
import { NativeTabs } from 'expo-router/unstable-native-tabs';
const homeIcon = MaterialDesignIcons.getImageSourceSync('home', 24, 'black');
const starOutlineIcon = MaterialDesignIcons.getImageSourceSync('star-outline', 24, 'black');
const starIcon = MaterialDesignIcons.getImageSourceSync('star', 24, 'black');
export default function TabLayout() {
return (
<NativeTabs>
<NativeTabs.Trigger name="index">
{/* `sf` is used on iOS, `src` (the vector icon) on Android. */}
<NativeTabs.Trigger.Icon sf="house" src={homeIcon} />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="explore">
{/* Outlined when unselected, filled when selected. */}
<NativeTabs.Trigger.Icon
sf={{ default: 'star', selected: 'star.fill' }}
src={{ default: starOutlineIcon, selected: starIcon }}
/>
</NativeTabs.Trigger>
</NativeTabs>
);
}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.
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>
);
}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.
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>
);
}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
hiddenproperty is available in SDK 55 and later.
You can hide the tab bar using hidden prop on the NativeTabs component. To hide tab bar for specific screens, you can use context API to set the hidden prop dynamically.
import { createContext } from 'react';
export const TabBarContext = createContext<{
setIsTabBarHidden: (hidden: boolean) => void;
}>({
setIsTabBarHidden: () => {},
});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>
);
}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.
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
hiddenmeans it cannot be navigated to in any way.
Dismiss behavior
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.
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>
);
}Scroll to top
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.
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>
);
}Disabled tabs
The
disabledprop is available in SDK 56 and later.
You can prevent native selection of a tab by setting the disabled prop on the NativeTabs.Trigger component. When true, tapping the tab in the tab bar does not change the focused tab. The tab remains visible - use hidden if you want to remove it from the tab bar entirely.
Note:
disabledonly suppresses the native tap interaction. It is not a "protected" or authorization gate - JavaScript navigation such asrouter.push('/settings')or<Link href="/settings" />still navigates to the tab.
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" disabled>
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
</NativeTabs>
);
}You can also toggle disabled dynamically from inside a screen.
import { NativeTabs } from 'expo-router/unstable-native-tabs';
import { View } from 'react-native';
export default function CheckoutScreen() {
const isProcessing = useIsProcessing();
return (
<View>
<NativeTabs.Trigger disabled={isProcessing} />
{/* ... */}
</View>
);
}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.
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>
);
}Tabbar search input
To add a search field to the tab bar, wrap the screen in a Stack navigator and configure headerSearchBarOptions.
src
app
_layout.tsx
index.tsx
search
_layout.tsx
index.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>
);
}import { Stack } from 'expo-router';
export default function SearchLayout() {
return <Stack />;
}import { ScrollView } from 'react-native';
import { Stack } from 'expo-router';
export default function SearchIndex() {
return (
<>
<Stack.Title>Search</Stack.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.
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>
);
}Bottom accessory
This feature is available in SDK 55 and later.
A bottom accessory is a floating view that appears above the tab bar, useful for displaying persistent controls like a mini music player. See Apple's UITabBarController bottomAccessory documentation for more details.
The bottom accessory can appear in two placements: 'regular' (standard position above the tab bar) or 'inline' (compact mode, inline with the tab bar). Use the usePlacement hook to adapt your UI based on the current placement.
You must store state outside the accessory component using props, context, or external state management. Two instances of the bottom accessory component are rendered simultaneously (one for each placement) and state is not shared between them.
The following example demonstrates a mini player with state lifted to the parent component:
import { NativeTabs } from 'expo-router/unstable-native-tabs';
import { useState } from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
function MiniPlayer({ isPlaying, onToggle }) {
const placement = NativeTabs.BottomAccessory.usePlacement();
if (placement === 'inline') {
// Compact UI for inline placement
return (
<Pressable onPress={onToggle} style={styles.inlinePlayer}>
<Text>{isPlaying ? '⏸' : '▶'}</Text>
</Pressable>
);
}
// Full UI for regular placement
return (
<View style={styles.regularPlayer}>
<Text>Now Playing: Song Title</Text>
<Pressable onPress={onToggle}>
<Text>{isPlaying ? 'Pause' : 'Play'}</Text>
</Pressable>
</View>
);
}
export default function TabLayout() {
// State must be stored outside BottomAccessory
const [isPlaying, setIsPlaying] = useState(false);
return (
<NativeTabs>
<NativeTabs.BottomAccessory>
<MiniPlayer isPlaying={isPlaying} onToggle={() => setIsPlaying(!isPlaying)} />
</NativeTabs.BottomAccessory>
<NativeTabs.Trigger name="index">
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="library">
<NativeTabs.Trigger.Label>Library</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
</NativeTabs>
);
}
const styles = StyleSheet.create({
inlinePlayer: {
padding: 8,
},
regularPlayer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 16,
},
});Safe area handling
This feature is available in SDK 55 and later.
Native tabs automatically handle safe area insets, with platform-specific behavior:
- iOS: The first
ScrollViewnested 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:
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>
);
}Lazy loading
All tab screens in native tabs render eagerly when the navigator mounts. This behavior cannot be changed because the native tab bar needs each screen to be available for transitions. If a tab contains expensive content that you want to defer until the user actually visits the tab, you can use one of the following approaches.
Render content only when focused
Use useIsFocused to conditionally render content. The content unmounts when the user navigates away and re-renders when they come back. This means any local state (scroll position, form inputs) is lost on every tab switch.
import { useIsFocused } from 'expo-router';
import { View, ActivityIndicator, Text } from 'react-native';
export default function SearchScreen() {
const isFocused = useIsFocused();
if (!isFocused) {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator />
</View>
);
}
return (
<View style={{ flex: 1 }}>
<Text>Expensive content that only renders when this tab is focused</Text>
</View>
);
}Load once on first focus
Use useFocusEffect with a state flag to load content the first time the tab is focused, then keep it mounted.
import { useFocusEffect } from 'expo-router';
import { useCallback, useState } from 'react';
import { View, ActivityIndicator, Text } from 'react-native';
export default function SearchScreen() {
const [hasActivated, setHasActivated] = useState(false);
useFocusEffect(
useCallback(() => {
setHasActivated(true);
}, [])
);
if (!hasActivated) {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator />
</View>
);
}
return (
<View style={{ flex: 1 }}>
<Text>Content that loads once and stays mounted</Text>
</View>
);
}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.
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.
import { ScrollView, View } from 'react-native';
export default function HomeScreen() {
return (
<View collapsable={false} style={{ flex: 1 }}>
<ScrollView>{/* Screen content */}</ScrollView>
</View>
);
}White background flashes when switching tabs on iOS 26
This happens because the default theme uses a white background color. To fix this, wrap your app in Expo Router's ThemeProvider with the appropriate theme.
ThemeProvider,DarkTheme, andDefaultThemeare exported fromexpo-routerin SDK 56 and later. For SDK 55, import them from@react-navigation/nativeinstead.
For apps supporting both light and dark modes:
import { ThemeProvider, DarkTheme, DefaultTheme } from 'expo-router';
import { NativeTabs } from 'expo-router/unstable-native-tabs';
import { useColorScheme } from 'react-native';
export default function TabLayout() {
const colorScheme = useColorScheme();
return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<NativeTabs>
<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>
</ThemeProvider>
);
}For dark-mode-only apps:
import { ThemeProvider, DarkTheme } from 'expo-router';
import { NativeTabs } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
return (
<ThemeProvider value={DarkTheme}>
<NativeTabs>{/* tabs */}</NativeTabs>
</ThemeProvider>
);
}Alternative for specific background colors:
If you need a specific background color that doesn't match the default themes, you can use the contentStyle prop on NativeTabs.Trigger:
<NativeTabs.Trigger name="index" contentStyle={{ backgroundColor: '#1a1a2e' }}>The tab bar background props have no effect on iOS 26
On iOS 26 and later, the system draws the tab bar with Liquid Glass and derives its background from the content behind it. The backgroundColor, blurEffect, shadowColor, and disableTransparentOnScrollEdge props affect the iOS tab bar only on iOS 18 and earlier.
This also means the tab bar does not follow a color scheme that exists only in JavaScript. Setting the color scheme with Appearance.setColorScheme changes the interface style that other native views resolve against, but it does not change the tab bar background either.
To make the tab bar match a dark UI on iOS 26, make the content behind the tab bar dark, as described in White background flashes when switching tabs on iOS 26.
Scroll to top does not work when tapping a tab
Tapping an active tab should scroll the content to the top, but this may not work if the ScrollView is not the first child of the screen component.
Ensure that the ScrollView is a direct first child of the screen component. If you wrap it with another component, make sure to set collapsable to false on the wrapper component.
import { ScrollView, View } from 'react-native';
export default function HomeScreen() {
return (
<View collapsable={false} style={{ flex: 1 }}>
<ScrollView>{/* Screen content */}</ScrollView>
</View>
);
}Liquid glass header buttons flicker in dark mode on iOS 26
Header buttons with liquid glass styling may flicker or flash their background when switching tabs in dark mode on iOS 26. This happens because the default theme doesn't match the system dark mode, causing visual artifacts in the liquid glass rendering.
The fix is the same as for the white background flash issue: wrap your layout with <ThemeProvider> from expo-router using the appropriate theme.
ThemeProvider,DarkTheme, andDefaultThemeare exported fromexpo-routerin SDK 56 and later. For SDK 55, import them from@react-navigation/nativeinstead.
For apps supporting both light and dark modes:
import { ThemeProvider, DarkTheme, DefaultTheme } from 'expo-router';
import { NativeTabs } from 'expo-router/unstable-native-tabs';
import { useColorScheme } from 'react-native';
export default function TabLayout() {
const colorScheme = useColorScheme();
return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<NativeTabs>
<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>
</ThemeProvider>
);
}For dark-mode-only apps:
import { ThemeProvider, DarkTheme } from 'expo-router';
import { NativeTabs } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
return (
<ThemeProvider value={DarkTheme}>
<NativeTabs>{/* tabs */}</NativeTabs>
</ThemeProvider>
);
}Known limitations
The default and selected icons share one rendering mode on iOS
On iOS, a tab's default and selected image icons must use the same rendering mode. When they resolve to different modes, both icons use the default icon's mode and Expo Router logs a warning in development.
The modes disagree when an icon color applies to only one of the states. This happens when you set tintColor, iconColor={{ selected }}, or the Icon selectedColor prop without also setting a color for the default state. A color implies 'template' rendering, while an uncolored icon defaults to 'original'. Setting renderingMode for only one state has the same effect.
To render both icons the same way, set a color for both states, or set renderingMode on the Icon:
import { NativeTabs } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
return (
// `iconColor` applies to both states, so both icons render as templates
<NativeTabs iconColor={{ default: 'gray', selected: 'black' }}>
<NativeTabs.Trigger name="settings">
<NativeTabs.Trigger.Icon
src={{
default: require('../assets/setting_icon.png'),
selected: require('../assets/selected_setting_icon.png'),
}}
/>
</NativeTabs.Trigger>
</NativeTabs>
);
}This limitation doesn't apply to SF Symbols, which the system always tints.
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.
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>
);
}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.
API reference
expo-router/unstable-native-tabs is a submodule of expo-router and exports components to build tab layouts using platform-native system tabs.
Installation
To use expo-router/unstable-native-tabs in your project, you need to install expo-router in your project. Follow the instructions from Expo Router's installation guide:
Install Expo Router — Learn how to install Expo Router in your project.
Configuration in app config
If you are using the default template to create a new project, expo-router's config plugin is already configured in your app config.
Example app.json with config plugin
{
"expo": {
"plugins": ["expo-router"]
}
}Usage
To learn how to use native tabs, with Expo Router, read the native tabs guide:
Native tabs — Learn how to use native tabs in your Expo Router app.
API
import { NativeTabs } from 'expo-router/unstable-native-tabs';Components
NativeTabs
Type: React.Element<NativeTabsProps>
The component used to create native tabs layout.
Example
import { NativeTabs } from 'expo-router/unstable-native-tabs';
export default function Layout() {
return (
<NativeTabs>
<NativeTabs.Trigger name="home" />
<NativeTabs.Trigger name="settings" />
</NativeTabs>
);
}NativeTabsProps
backgroundColor
Optional • Type: ColorValue
The background color of the tab bar.
badgeBackgroundColor
Optional • Type: ColorValue
The background color of every badge in the tab bar.
blurEffect
Optional • Literal type: string
The blur effect applied to the tab bar.
Acceptable values are: 'none' | 'regular' | 'light' | 'dark' | 'systemDefault' | 'extraLight' | 'prominent' | 'systemUltraThinMaterial' | 'systemThinMaterial' | 'systemMaterial' | 'systemThickMaterial' | 'systemChromeMaterial' | 'systemUltraThinMaterialLight' | 'systemThinMaterialLight' | 'systemMaterialLight' | 'systemThickMaterialLight' | 'systemChromeMaterialLight' | 'systemUltraThinMaterialDark' | 'systemThinMaterialDark' | 'systemMaterialDark' | 'systemThickMaterialDark' | 'systemChromeMaterialDark'
disableTransparentOnScrollEdge
Optional • Type: boolean
When set to true, the tab bar will not become transparent when scrolled to the edge.
hidden
Optional • Type: boolean • Default: false
When set to true, hides the tab bar.
iconColor
Optional • Literal type: union
The color of every tab icon in the tab bar.
Acceptable values are: ColorValue | { default: ColorValue | undefined, selected: ColorValue | undefined }
labelStyle
Optional • Literal type: union
The style of the every tab label in the tab bar.
Acceptable values are: StyleProp<NativeTabsLabelStyle> | { default: StyleProp<NativeTabsLabelStyle>, selected: StyleProp<NativeTabsLabelStyle> }
minimizeBehavior
iOS 26+
Optional • Literal type: string • Default: automatic
Specifies the minimize behavior for the tab bar.
Available starting from iOS 26.
The following values are currently supported:
automatic- resolves to the system default minimize behaviornever- the tab bar does not minimizeonScrollDown- the tab bar minimizes when scrolling down and expands when scrolling back uponScrollUp- the tab bar minimizes when scrolling up and expands when scrolling back down
See: The supported values correspond to the official Apple documentation.
Acceptable values are: 'automatic' | 'never' | 'onScrollDown' | 'onScrollUp'
screenListeners
Optional • Literal type: union
Listeners for navigation events on all tabs.
Supported events:
tabPress- called when a tab is pressedfocus- called when the screen comes into focusblur- called when the screen loses focus
Example
<NativeTabs
screenListeners={{
tabPress: (e) => {
console.log('Any tab pressed');
},
}}
>
...
</NativeTabs>Acceptable values are: (prop: { route: RouteProp<ParamListBase, string> }) => ScreenListeners<TabNavigationState<ParamListBase>, NativeTabNavigationEventMap> | Partial<{ beforeRemove: EventListenerCallback<NativeTabNavigationEventMap & EventMapCore<TabNavigationState<ParamListBase>>, 'beforeRemove', true>, blur: EventListenerCallback<NativeTabNavigationEventMap & EventMapCore<TabNavigationState<ParamListBase>>, 'blur', unknown>, focus: EventListenerCallback<NativeTabNavigationEventMap & EventMapCore<TabNavigationState<ParamListBase>>, 'focus', unknown>, state: EventListenerCallback<NativeTabNavigationEventMap & EventMapCore<TabNavigationState<ParamListBase>>, 'state', unknown>, tabPress: EventListenerCallback<NativeTabNavigationEventMap & EventMapCore<TabNavigationState<ParamListBase>>, 'tabPress', false> }>
shadowColor
Optional • Type: ColorValue
The color of the shadow.
See: Apple documentation
sidebarAdaptable
iOS 18+
Optional • Type: boolean
When set to true, enables the sidebarAdaptable tab bar style on iPadOS and macOS. This prop has no effect on iPhone.
tintColor
Optional • Type: ColorValue
The tint color of the tab icon.
Can be overridden by icon color and label color for each tab individually.
titlePositionAdjustment
Optional • Type: { horizontal: number, vertical: number }
See: Apple documentation
unstable_nativeProps
Optional • Type: Partial<Omit<TabsHostProps, 'children' | 'navStateRequest' | 'onTabSelected'>>
Props passed to the underlying native tab host implementation in react-native-screens. Use this to configure props that are not directly exposed by Expo Router.
Note: This is an unstable API and may change or be removed in minor versions.
Inherited props
PropsWithChildren
NativeTabs.Trigger
Type: React.Element<NativeTabTriggerProps>
The component used to customize the native tab options both in the _layout file and from the tab screen.
When used in the _layout file, you need to provide a name prop. When used in the tab screen, the name prop takes no effect.
Example
import { NativeTabs } from 'expo-router/unstable-native-tabs';
export default function Layout() {
return (
<NativeTabs>
<NativeTabs.Trigger name="home" />
<NativeTabs.Trigger name="settings" />
</NativeTabs>
);
}Example
import { NativeTabs } from 'expo-router/unstable-native-tabs';
export default function HomeScreen() {
return (
<View>
<NativeTabs.Trigger>
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<Text>This is home screen!</Text>
</View>
);
}NativeTabTriggerProps
children
Optional • Type: ReactNode
The children of the trigger.
Use Icon, Label, and Badge components to customize the tab.
contentStyle
Optional • Type: Pick<ViewStyle, 'backgroundColor' | 'experimental_backgroundImage' | 'alignContent' | 'alignItems' | 'flexDirection' | 'gap' | 'justifyContent' | 'padding' | 'paddingBottom' | 'paddingEnd' | 'paddingHorizontal' | 'paddingLeft' | 'paddingRight' | 'paddingStart' | 'paddingTop' | 'paddingVertical' | 'paddingBlock' | 'paddingBlockEnd' | 'paddingBlockStart' | 'paddingInline' | 'paddingInlineEnd' | 'paddingInlineStart'>
The style applied to the content of the tab
Note: Only certain style properties are supported.
disableAutomaticContentInsets
Optional • Type: boolean
The default behavior differs between iOS and Android.
On iOS, the first scroll view nested inside a native tabs screen has automatic content inset adjustment enabled
When this property is set to true, automatic content inset adjustment is disabled for the screen and must be managed manually. You can use SafeAreaView from react-native-screens/experimental to handle safe area insets.
disabled
Optional • Type: boolean • Default: false
If true, the tab is shown but cannot be selected by tapping it in the tab bar.
Note: This only suppresses the native tap interaction. JavaScript navigation such as
router.push()or<Link />still navigates to the tab. Use this for tabs that should appear visible but be temporarily inert, and gate navigation in your own code if you need to fully prevent access.
Unlike hidden, the tab remains visible in the tab bar.
disablePopToTop
Optional • Type: boolean • Default: false
If true, the tab will not pop stack to the root when selected again.
disableScrollToTop
Optional • Type: boolean • Default: false
If true, the tab will not scroll to the top when selected again.
disableTransparentOnScrollEdge
Optional • Type: boolean
When set to true, the tab bar will not become transparent when scrolled to the edge.
When set on a trigger, it takes precedence over the value set on NativeTabs.
hidden
Optional • Type: boolean
If true, the tab will be hidden from the tab bar.
Note: Marking a tab as
hiddenmeans it cannot be navigated to in any way.
Note: Dynamically hiding tabs will remount the navigator and the state will be reset.
listeners
Optional • Literal type: union
Listeners for navigation events on this tab.
Supported events:
tabPress- called when this tab is pressedfocus- called when this screen comes into focusblur- called when this screen loses focus
Example
<NativeTabs.Trigger
name="home"
listeners={{
tabPress: (e) => {
console.log('Home tab pressed');
},
}}
/>Acceptable values are: Partial<{ beforeRemove: EventListenerCallback<NativeTabNavigationEventMap & EventMapCore<TabNavigationState<ParamListBase>>, 'beforeRemove', true>, blur: EventListenerCallback<NativeTabNavigationEventMap & EventMapCore<TabNavigationState<ParamListBase>>, 'blur', unknown>, focus: EventListenerCallback<NativeTabNavigationEventMap & EventMapCore<TabNavigationState<ParamListBase>>, 'focus', unknown>, state: EventListenerCallback<NativeTabNavigationEventMap & EventMapCore<TabNavigationState<ParamListBase>>, 'state', unknown>, tabPress: EventListenerCallback<NativeTabNavigationEventMap & EventMapCore<TabNavigationState<ParamListBase>>, 'tabPress', false> }> | (prop: { navigation: any, route: RouteProp<ParamListBase, string> }) => ScreenListeners<TState, TEventMap>
name
Optional • Type: string
The name of the route.
This is required when used inside a Layout component.
When used in a route it has no effect.
role
Optional • Literal type: string
System-provided tab bar item with predefined icon and title
Uses Apple's built-in tab bar items (e.g., bookmarks, contacts, downloads) with standard iOS styling and localized titles. Custom icon or selectedIcon properties will override the system icon, but the system-defined title cannot be customized.
See: The supported values correspond to the official Apple documentation.
Acceptable values are: 'search' | 'history' | 'bookmarks' | 'contacts' | 'downloads' | 'favorites' | 'featured' | 'more' | 'mostRecent' | 'mostViewed' | 'recents' | 'topRated'
unstable_nativeProps
Optional • Type: Partial<Omit<TabsScreenProps, 'screenKey'>>
Props passed to the underlying native tab screen implementation. Use this to configure props not directly exposed by Expo Router, but available in react-native-screens.
Note: This will override any other props set by Expo Router and may lead to unexpected behavior.
Note: This is an unstable API and may change or be removed in minor versions.
NativeTabs.BottomAccessory
Type: React.Element<FC<NativeTabsBottomAccessoryProps> & { usePlacement: () => 'regular' | 'inline' }>
NativeTabsBottomAccessoryProps
children
Optional • Type: ReactNode
NativeTabs.Trigger.Badge
Type: React.Element<FC<NativeTabsTriggerBadgeProps>>
NativeTabsTriggerBadgeProps
children
Optional • Type: string
The text to display as the badge for the tab. If not provided, the badge will not be displayed.
hidden
Optional • Type: boolean • Default: false
If true, the badge will be hidden.
selectedBackgroundColor
Optional • Type: ColorValue
NativeTabs.Trigger.Icon
Type: React.Element<FC<NativeTabsTriggerIconProps>>
NativeTabsTriggerIconProps
selectedColor
Optional • Type: ColorValue
NativeTabs.Trigger.Label
Type: React.Element<FC<NativeTabsTriggerLabelProps>>
NativeTabsTriggerLabelProps
children
Optional • Type: string
The text to display as the label for the tab.
hidden
Optional • Type: boolean • Default: false
If true, the label will be hidden.
selectedStyle
Optional • Type: StyleProp<NativeTabsLabelStyle>
NativeTabs.Trigger.VectorIcon
Type: React.Element<VectorIconProps<NameT>>
Helper component for loading vector icons.
Prefer using the md and sf props on Icon rather than using this component directly. Only use this component when you need to load a specific icon from a vector icon family.
Example
import { Icon, VectorIcon } from 'expo-router';
import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons';
<Icon src={<VectorIcon family={MaterialCommunityIcons} name="home" />} />Interfaces
DrawableIcon
| Property | Type | Description |
|---|
- As a string with the drawable resource name
- As an object specifying the default and selected states
. Example.
<Icon drawable="ic_home" />. Example.
<Icon drawable={{ default: 'ic_home_outline', selected: 'ic_home_filled' }} />|
SFSymbolIcon
| Property | Type | Description |
|---|---|---|
| sf(optional) | SFSymbols7_0 | { default: SFSymbols7_0 | undefined, selected: SFSymbols7_0 } | The name of the SF Symbol to use as an icon. The value can be provided in two ways |
- As a string with the SF Symbol name
- As an object specifying the default and selected states
. Example.
<Icon sf="magnifyingglass" />. Example.
<Icon sf={{ default: "house", selected: "house.fill" }} />|
SrcIcon
| Property | Type | Description |
|---|---|---|
| renderingMode(optional) | 'template' | 'original' | Controls how the image icon is rendered on iOS |
'template': iOS applies tint color to the icon (selected/unselected states)'original': Preserves original icon colors
. Default behavior:
- If tab bar icon color is configured, defaults to
'template' - If no icon color is set, defaults to
'original'
. See: Apple documentation for more information. |
| src(optional) | ReactElement<unknown, string | JSXElementConstructor<any>> | ImageSourcePropType | { default: ReactElement<unknown, string | JSXElementConstructor<any>> | ImageSourcePropType | undefined, selected: ReactElement<unknown, string | JSXElementConstructor<any>> | ImageSourcePropType } | The image source to use as an icon. When sf prop is used it will override this prop on iOS. When drawable or material prop is used it will override this prop on Android. The value can be provided in two ways|
- As an image source
- As an object specifying the default and selected states
. Example.
<Icon src={require('./path/to/icon.png')} />. Example.
<Icon src={{ default: require('./path/to/icon.png'), selected: require('./path/to/icon-selected.png') }} />|
XcassetIcon
| Property | Type | Description |
|---|---|---|
| xcasset(optional) | string | { default: string, selected: string } | The name of the iOS asset catalog image to use as an icon. Xcassets provide automatic multi-resolution (@1x/@2x/@3x), dark mode variants, and device-specific images via [UIImage imageNamed:]. The rendering mode (template vs original) can be controlled via the renderingMode prop on the Icon component. By default, icons are tinted when iconColor is set, and rendered as original otherwise. The value can be provided in two ways |
- As a string with the asset catalog image name
- As an object specifying the default and selected states
. Example.
<Icon xcasset="custom-icon" />. Example.
<Icon xcasset={{ default: "home-outline", selected: "home-filled" }} />|
Types
NativeTabsLabelStyle
Type: Pick<TextStyle, 'fontFamily' | 'fontSize' | 'fontStyle' | 'fontWeight' | 'color'>
SymbolOrImageSource
Type: object shaped as below:
| Property | Type | Description |
|---|---|---|
| sf(optional) | SFSymbol | The name of the SF Symbol to use as an icon. |
| xcasset(optional) | string | The name of the iOS asset catalog image to use as an icon. |
Or object shaped as below:
| Property | Type | Description |
|---|---|---|
| renderingMode(optional) | 'template' | 'original' | Controls how the icon is rendered on iOS. Default: 'template' |
| src(optional) | ImageSourcePropType | Promise<ImageSourcePropType | null> | The image source to use as an icon. |
Sources
The original Expo docs for this page:
- Native tabs (
source-of-truth/router/advanced/native-tabs.md) - Router Native tabs (
source-of-truth/versions/v57.0.0/sdk/router/native-tabs.md)