# Native Tabs Always prefer NativeTabs from 'expo-router/unstable-native-tabs' for the best iOS experience. **SDK 54+. SDK 55 recommended.** ## SDK Compatibility | Aspect | SDK 54 | SDK 55+ | | ------------- | ------------------------------------------------------- | ----------------------------------------------------------- | | Import | `import { NativeTabs, Icon, Label, Badge, VectorIcon }` | `import { NativeTabs }` only | | Icon | `` | `` | | Label | `` | `Home` | | Badge | `9+` | `9+` | | Android icons | `drawable` prop | `md` prop (Material Symbols) | All examples below use SDK 55 syntax. For SDK 54, replace `NativeTabs.Trigger.Icon/Label/Badge` with standalone `Icon`, `Label`, `Badge` imports. ## Basic Usage ```tsx import { NativeTabs } from "expo-router/unstable-native-tabs"; export default function TabLayout() { return ( Home 9+ Settings Search ); } ``` ## Rules - You must include a trigger for each tab - The `NativeTabs.Trigger` 'name' must match the route name, including parentheses (e.g. ``) - Prefer search tab to be last in the list so it can combine with the search bar - Use the 'role' prop for common tab types - Tabs must be static — no dynamic addition/removal at runtime (remounts navigator, loses state) ## Platform Features Native Tabs use platform-specific tab bar implementations: - **iOS 26+**: Liquid glass effects with system-native appearance - **Android**: Material 3 bottom navigation - Better performance and native feel ## Icon Component ```tsx // SF Symbol (iOS) + Material Symbol (Android) // State variants // Custom image // Xcode asset catalog — iOS only (SDK 55+) // Rendering mode — iOS only (SDK 55+) ``` `renderingMode`: `"template"` applies tint color (single-color icons), `"original"` preserves source colors (gradients). Android always uses original. ## Label & Badge ```tsx // Label Home {/* icon-only tab */} // Badge 9+ {/* dot indicator */} ``` ## iOS 26 Features ### Liquid Glass Tab Bar The tab bar automatically adopts liquid glass appearance on iOS 26+. ### Minimize on Scroll ```tsx ``` ### Search Tab ```tsx Search ``` **Note**: Place search tab last for best UX. ### Role Prop Use semantic roles for special tab types: ```tsx ``` Available roles: `search` | `more` | `favorites` | `bookmarks` | `contacts` | `downloads` | `featured` | `history` | `mostRecent` | `mostViewed` | `recents` | `topRated` ## Customization ### Tint Color ```tsx ``` ### Dynamic Colors (iOS) Use DynamicColorIOS for colors that adapt to liquid glass: ```tsx import { DynamicColorIOS, Platform } from 'react-native'; const adaptiveBlue = Platform.select({ ios: DynamicColorIOS({ light: '#007AFF', dark: '#0A84FF' }), default: '#007AFF', }); ``` ## Conditional Tabs ```tsx ``` **Don't hide the tabs when they are visible - toggling visibility remounts the navigator; Do it only during the initial render.** **Note**: Hidden tabs cannot be navigated to! ## Behavior Options ```tsx ``` ## Hidden Tab Bar (SDK 55+) Use `hidden` prop on `NativeTabs` to hide the entire tab bar dynamically: ```tsx ``` ## Bottom Accessory (SDK 55+) `NativeTabs.BottomAccessory` renders content above the tab bar (iOS 26+). Uses `usePlacement()` to adapt between `'regular'` and `'inline'` layouts. **Important**: Two instances render simultaneously — store state outside the component (props, context, or external store). ```tsx import { NativeTabs } from "expo-router/unstable-native-tabs"; import { useState } from "react"; import { Pressable, Text, View } from "react-native"; function MiniPlayer({ isPlaying, onToggle, }: { isPlaying: boolean; onToggle: () => void; }) { const placement = NativeTabs.BottomAccessory.usePlacement(); if (placement === "inline") { return ( ); } return {/* full player UI */}; } export default function TabLayout() { const [isPlaying, setIsPlaying] = useState(false); return ( setIsPlaying(!isPlaying)} /> Home ); } ``` ## Safe Area Handling (SDK 55+) SDK 55 handles safe areas automatically: - **Android**: Content wrapped in SafeAreaView (bottom inset) - **iOS**: First ScrollView gets automatic `contentInsetAdjustmentBehavior` To opt out per-tab, use `disableAutomaticContentInsets` and manage manually: ```tsx Home ``` ```tsx // In the screen import { SafeAreaView } from "react-native-screens/experimental"; export default function HomeScreen() { return ( {/* content */} ); } ``` ## Using Vector Icons If you must use @expo/vector-icons instead of SF Symbols: ```tsx import { NativeTabs } from "expo-router/unstable-native-tabs"; import Ionicons from "@expo/vector-icons/Ionicons"; Home ``` **Prefer SF Symbols + `md` prop over vector icons for native feel.** If you are using SDK 55 and later **use the md prop to specify Material Symbols used on Android**. ## Structure with Stacks Native tabs don't render headers. Nest Stacks inside each tab for navigation headers: ```tsx // app/(tabs)/_layout.tsx import { NativeTabs } from "expo-router/unstable-native-tabs"; export default function TabLayout() { return ( Home ); } // app/(tabs)/(home)/_layout.tsx import Stack from "expo-router/stack"; export default function HomeStack() { return ( ); } ``` ## Custom Web Layout Use platform-specific files for separate native and web tab layouts: ``` app/ _layout.tsx # NativeTabs for iOS/Android _layout.web.tsx # Headless tabs for web (expo-router/ui) ``` Or extract to a component: `components/app-tabs.tsx` + `components/app-tabs.web.tsx`. ## Migration from JS Tabs ### Before (JS Tabs) ```tsx import { Tabs } from "expo-router"; , tabBarBadge: 3, }} /> ; ``` ### After (Native Tabs) ```tsx import { NativeTabs } from "expo-router/unstable-native-tabs"; Home 3 ; ``` ### Key Differences | JS Tabs | Native Tabs | | -------------------------- | ---------------------------- | | `` | `` | | `options={{ title }}` | `` | | `options={{ tabBarIcon }}` | `` | | `tabBarBadge` option | `` | | Props-based API | Component-based API | | Headers built-in | Nest `` for headers | ## Limitations - **Android**: Maximum 5 tabs (Material Design constraint) - **Nesting**: Native tabs cannot nest inside other native tabs - **Tab bar height**: Cannot be measured programmatically - **FlatList transparency**: Use `disableTransparentOnScrollEdge` to fix issues - **Dynamic tabs**: Tabs must be static; changes remount navigator and lose state ## Keyboard Handling (Android) Configure in app.json: ```json { "expo": { "android": { "softwareKeyboardLayoutMode": "resize" } } } ``` ## Common Issues 1. **Icons not showing on Android**: Add `md` prop (SDK 55) or use VectorIcon 2. **Headers missing**: Nest a Stack inside each tab group 3. **Trigger name mismatch**: `name` must match exact route name including parentheses 4. **Badge not visible**: Badge must be a child of Trigger, not a prop 5. **Tab bar transparent on iOS 18 and earlier**: If the screen uses a `ScrollView` or `FlatList`, make sure it is the first opaque child of the screen component. If it needs to be wrapped in another `View`, ensure the wrapper uses `collapsable={false}`. If the screen does not use a `ScrollView` or `FlatList`, set `disableTransparentOnScrollEdge` to `true` in the `NativeTabs.Trigger` options, to make the tab bar opaque. 6. **Scroll to top not working**: Ensure `disableScrollToTop` is not set on the active tab's Trigger and `ScrollView` is the first child of the screen component. 7. **Header buttons flicker when navigating between tabs**: Make sure the app is wrapped in a `ThemeProvider` ```tsx import { ThemeProvider, DarkTheme, DefaultTheme, } from "@react-navigation/native"; import { useColorScheme } from "react-native"; import { Stack } from "expo-router"; export default function Layout() { const colorScheme = useColorScheme(); return ( ); } ``` If the app only uses a light or dark theme, you can directly pass `DarkTheme` or `DefaultTheme` to `ThemeProvider` without checking the color scheme. ```tsx import { ThemeProvider, DarkTheme } from "@react-navigation/native"; import { Stack } from "expo-router"; export default function Layout() { return ( ); } ```