Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Official Expo team skill for building native UI in Expo apps, fine-tuned for Opus models and usable with Claude Code or Cursor.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
references/tabs.md
1# Native Tabs23Always prefer NativeTabs from 'expo-router/unstable-native-tabs' for the best iOS experience.45**SDK 54+. SDK 55 recommended.**67## SDK Compatibility89| Aspect | SDK 54 | SDK 55+ |10| ------------- | ------------------------------------------------------- | ----------------------------------------------------------- |11| Import | `import { NativeTabs, Icon, Label, Badge, VectorIcon }` | `import { NativeTabs }` only |12| Icon | `<Icon sf="house.fill" />` | `<NativeTabs.Trigger.Icon sf="house.fill" />` |13| Label | `<Label>Home</Label>` | `<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>` |14| Badge | `<Badge>9+</Badge>` | `<NativeTabs.Trigger.Badge>9+</NativeTabs.Trigger.Badge>` |15| Android icons | `drawable` prop | `md` prop (Material Symbols) |1617All examples below use SDK 55 syntax. For SDK 54, replace `NativeTabs.Trigger.Icon/Label/Badge` with standalone `Icon`, `Label`, `Badge` imports.1819## Basic Usage2021```tsx22import { NativeTabs } from "expo-router/unstable-native-tabs";2324export default function TabLayout() {25return (26<NativeTabs minimizeBehavior="onScrollDown">27<NativeTabs.Trigger name="index">28<NativeTabs.Trigger.Icon sf="house.fill" md="home" />29<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>30<NativeTabs.Trigger.Badge>9+</NativeTabs.Trigger.Badge>31</NativeTabs.Trigger>32<NativeTabs.Trigger name="settings">33<NativeTabs.Trigger.Icon sf="gear" md="settings" />34<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>35</NativeTabs.Trigger>36<NativeTabs.Trigger name="(search)" role="search">37<NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>38</NativeTabs.Trigger>39</NativeTabs>40);41}42```4344## Rules4546- You must include a trigger for each tab47- The `NativeTabs.Trigger` 'name' must match the route name, including parentheses (e.g. `<NativeTabs.Trigger name="(search)">`)48- Prefer search tab to be last in the list so it can combine with the search bar49- Use the 'role' prop for common tab types50- Tabs must be static — no dynamic addition/removal at runtime (remounts navigator, loses state)5152## Platform Features5354Native Tabs use platform-specific tab bar implementations:5556- **iOS 26+**: Liquid glass effects with system-native appearance57- **Android**: Material 3 bottom navigation58- Better performance and native feel5960## Icon Component6162```tsx63// SF Symbol (iOS) + Material Symbol (Android)64<NativeTabs.Trigger.Icon sf="house.fill" md="home" />6566// State variants67<NativeTabs.Trigger.Icon sf={{ default: "house", selected: "house.fill" }} md="home" />6869// Custom image70<NativeTabs.Trigger.Icon src={require('./icon.png')} />7172// Xcode asset catalog — iOS only (SDK 55+)73<NativeTabs.Trigger.Icon xcasset="home-icon" />74<NativeTabs.Trigger.Icon xcasset={{ default: "home-outline", selected: "home-filled" }} />7576// Rendering mode — iOS only (SDK 55+)77<NativeTabs.Trigger.Icon src={require('./icon.png')} renderingMode="template" />78<NativeTabs.Trigger.Icon src={require('./gradient.png')} renderingMode="original" />79```8081`renderingMode`: `"template"` applies tint color (single-color icons), `"original"` preserves source colors (gradients). Android always uses original.8283## Label & Badge8485```tsx86// Label87<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>88<NativeTabs.Trigger.Label hidden>Home</NativeTabs.Trigger.Label> {/* icon-only tab */}8990// Badge91<NativeTabs.Trigger.Badge>9+</NativeTabs.Trigger.Badge>92<NativeTabs.Trigger.Badge /> {/* dot indicator */}93```9495## iOS 26 Features9697### Liquid Glass Tab Bar9899The tab bar automatically adopts liquid glass appearance on iOS 26+.100101### Minimize on Scroll102103```tsx104<NativeTabs minimizeBehavior="onScrollDown">105```106107### Search Tab108109```tsx110<NativeTabs.Trigger name="(search)" role="search">111<NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>112</NativeTabs.Trigger>113```114115**Note**: Place search tab last for best UX.116117### Role Prop118119Use semantic roles for special tab types:120121```tsx122<NativeTabs.Trigger name="search" role="search" />123<NativeTabs.Trigger name="favorites" role="favorites" />124<NativeTabs.Trigger name="more" role="more" />125```126127Available roles: `search` | `more` | `favorites` | `bookmarks` | `contacts` | `downloads` | `featured` | `history` | `mostRecent` | `mostViewed` | `recents` | `topRated`128129## Customization130131### Tint Color132133```tsx134<NativeTabs tintColor="#007AFF">135```136137### Dynamic Colors (iOS)138139Use DynamicColorIOS for colors that adapt to liquid glass:140141```tsx142import { DynamicColorIOS, Platform } from 'react-native';143144const adaptiveBlue = Platform.select({145ios: DynamicColorIOS({ light: '#007AFF', dark: '#0A84FF' }),146default: '#007AFF',147});148149<NativeTabs tintColor={adaptiveBlue}>150```151152## Conditional Tabs153154```tsx155<NativeTabs.Trigger name="admin" hidden={!isAdmin}>156<NativeTabs.Trigger.Label>Admin</NativeTabs.Trigger.Label>157<NativeTabs.Trigger.Icon sf="shield.fill" md="shield" />158</NativeTabs.Trigger>159```160161**Don't hide the tabs when they are visible - toggling visibility remounts the navigator; Do it only during the initial render.**162163**Note**: Hidden tabs cannot be navigated to!164165## Behavior Options166167```tsx168<NativeTabs.Trigger169name="home"170disablePopToTop // Don't pop stack when tapping active tab171disableScrollToTop // Don't scroll to top when tapping active tab172disableAutomaticContentInsets // Opt out of automatic safe area insets (SDK 55+)173>174```175176## Hidden Tab Bar (SDK 55+)177178Use `hidden` prop on `NativeTabs` to hide the entire tab bar dynamically:179180```tsx181<NativeTabs hidden={isTabBarHidden}>{/* triggers */}</NativeTabs>182```183184## Bottom Accessory (SDK 55+)185186`NativeTabs.BottomAccessory` renders content above the tab bar (iOS 26+). Uses `usePlacement()` to adapt between `'regular'` and `'inline'` layouts.187188**Important**: Two instances render simultaneously — store state outside the component (props, context, or external store).189190```tsx191import { NativeTabs } from "expo-router/unstable-native-tabs";192import { useState } from "react";193import { Pressable, Text, View } from "react-native";194195function MiniPlayer({196isPlaying,197onToggle,198}: {199isPlaying: boolean;200onToggle: () => void;201}) {202const placement = NativeTabs.BottomAccessory.usePlacement();203if (placement === "inline") {204return (205<Pressable onPress={onToggle}>206<SymbolView name={isPlaying ? "pause.fill" : "play.fill"} />207</Pressable>208);209}210return <View>{/* full player UI */}</View>;211}212213export default function TabLayout() {214const [isPlaying, setIsPlaying] = useState(false);215return (216<NativeTabs>217<NativeTabs.BottomAccessory>218<MiniPlayer219isPlaying={isPlaying}220onToggle={() => setIsPlaying(!isPlaying)}221/>222</NativeTabs.BottomAccessory>223<NativeTabs.Trigger name="index">224<NativeTabs.Trigger.Icon sf="house.fill" md="home" />225<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>226</NativeTabs.Trigger>227</NativeTabs>228);229}230```231232## Safe Area Handling (SDK 55+)233234SDK 55 handles safe areas automatically:235236- **Android**: Content wrapped in SafeAreaView (bottom inset)237- **iOS**: First ScrollView gets automatic `contentInsetAdjustmentBehavior`238239To opt out per-tab, use `disableAutomaticContentInsets` and manage manually:240241```tsx242<NativeTabs.Trigger name="index" disableAutomaticContentInsets>243<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>244</NativeTabs.Trigger>245```246247```tsx248// In the screen249import { SafeAreaView } from "react-native-screens/experimental";250251export default function HomeScreen() {252return (253<SafeAreaView edges={{ bottom: true }} style={{ flex: 1 }}>254{/* content */}255</SafeAreaView>256);257}258```259260## Using Vector Icons261262If you must use @expo/vector-icons instead of SF Symbols:263264```tsx265import { NativeTabs } from "expo-router/unstable-native-tabs";266import Ionicons from "@expo/vector-icons/Ionicons";267268<NativeTabs.Trigger name="home">269<NativeTabs.Trigger.VectorIcon vector={Ionicons} name="home" />270<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>271</NativeTabs.Trigger>272```273274**Prefer SF Symbols + `md` prop over vector icons for native feel.**275276If you are using SDK 55 and later **use the md prop to specify Material Symbols used on Android**.277278## Structure with Stacks279280Native tabs don't render headers. Nest Stacks inside each tab for navigation headers:281282```tsx283// app/(tabs)/_layout.tsx284import { NativeTabs } from "expo-router/unstable-native-tabs";285286export default function TabLayout() {287return (288<NativeTabs>289<NativeTabs.Trigger name="(home)">290<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>291<NativeTabs.Trigger.Icon sf="house.fill" md="home" />292</NativeTabs.Trigger>293</NativeTabs>294);295}296297// app/(tabs)/(home)/_layout.tsx298import Stack from "expo-router/stack";299300export default function HomeStack() {301return (302<Stack>303<Stack.Screen304name="index"305options={{ title: "Home", headerLargeTitle: true }}306/>307<Stack.Screen name="details" options={{ title: "Details" }} />308</Stack>309);310}311```312313## Custom Web Layout314315Use platform-specific files for separate native and web tab layouts:316317```318app/319_layout.tsx # NativeTabs for iOS/Android320_layout.web.tsx # Headless tabs for web (expo-router/ui)321```322323Or extract to a component: `components/app-tabs.tsx` + `components/app-tabs.web.tsx`.324325## Migration from JS Tabs326327### Before (JS Tabs)328329```tsx330import { Tabs } from "expo-router";331332<Tabs>333<Tabs.Screen334name="index"335options={{336title: "Home",337tabBarIcon: ({ color }) => <IconSymbol name="house.fill" color={color} />,338tabBarBadge: 3,339}}340/>341</Tabs>;342```343344### After (Native Tabs)345346```tsx347import { NativeTabs } from "expo-router/unstable-native-tabs";348349<NativeTabs>350<NativeTabs.Trigger name="index">351<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>352<NativeTabs.Trigger.Icon sf="house.fill" md="home" />353<NativeTabs.Trigger.Badge>3</NativeTabs.Trigger.Badge>354</NativeTabs.Trigger>355</NativeTabs>;356```357358### Key Differences359360| JS Tabs | Native Tabs |361| -------------------------- | ---------------------------- |362| `<Tabs.Screen>` | `<NativeTabs.Trigger>` |363| `options={{ title }}` | `<NativeTabs.Trigger.Label>` |364| `options={{ tabBarIcon }}` | `<NativeTabs.Trigger.Icon>` |365| `tabBarBadge` option | `<NativeTabs.Trigger.Badge>` |366| Props-based API | Component-based API |367| Headers built-in | Nest `<Stack>` for headers |368369## Limitations370371- **Android**: Maximum 5 tabs (Material Design constraint)372- **Nesting**: Native tabs cannot nest inside other native tabs373- **Tab bar height**: Cannot be measured programmatically374- **FlatList transparency**: Use `disableTransparentOnScrollEdge` to fix issues375- **Dynamic tabs**: Tabs must be static; changes remount navigator and lose state376377## Keyboard Handling (Android)378379Configure in app.json:380381```json382{383"expo": {384"android": {385"softwareKeyboardLayoutMode": "resize"386}387}388}389```390391## Common Issues3923931. **Icons not showing on Android**: Add `md` prop (SDK 55) or use VectorIcon3942. **Headers missing**: Nest a Stack inside each tab group3953. **Trigger name mismatch**: `name` must match exact route name including parentheses3964. **Badge not visible**: Badge must be a child of Trigger, not a prop3975. **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.3986. **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.3997. **Header buttons flicker when navigating between tabs**: Make sure the app is wrapped in a `ThemeProvider`400401```tsx402import {403ThemeProvider,404DarkTheme,405DefaultTheme,406} from "@react-navigation/native";407import { useColorScheme } from "react-native";408import { Stack } from "expo-router";409410export default function Layout() {411const colorScheme = useColorScheme();412return (413<ThemeProvider theme={colorScheme === "dark" ? DarkTheme : DefaultTheme}>414<Stack />415</ThemeProvider>416);417}418```419420If the app only uses a light or dark theme, you can directly pass `DarkTheme` or `DefaultTheme` to `ThemeProvider` without checking the color scheme.421422```tsx423import { ThemeProvider, DarkTheme } from "@react-navigation/native";424import { Stack } from "expo-router";425426export default function Layout() {427return (428<ThemeProvider theme={DarkTheme}>429<Stack />430</ThemeProvider>431);432}433```434