Files
2026-01-30 03:04:10 +00:00

7.8 KiB

Native Tabs

Always prefer NativeTabs from 'expo-router/unstable-native-tabs' for the best iOS experience.

Requires SDK 54+

Basic Usage

import {
  NativeTabs,
  Icon,
  Label,
  Badge,
} from "expo-router/unstable-native-tabs";

export default function TabLayout() {
  return (
    <NativeTabs minimizeBehavior="onScrollDown">
      <NativeTabs.Trigger name="index">
        <Label>Home</Label>
        <Icon sf="house.fill" />
        <Badge>9+</Badge>
      </NativeTabs.Trigger>
      <NativeTabs.Trigger name="settings">
        <Icon sf="gear" />
        <Label>Settings</Label>
      </NativeTabs.Trigger>
      <NativeTabs.Trigger name="(search)" role="search">
        <Label>Search</Label>
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}

Rules

  • You must include a trigger for each tab
  • The NativeTabs.Trigger 'name' must match the route name, including parentheses (e.g. <NativeTabs.Trigger name="(search)">)
  • 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

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

// SF Symbol only (iOS)
<Icon sf="house.fill" />

// With Android drawable
<Icon sf="house.fill" drawable="ic_home" />

// Custom image source
<Icon src={require('./icon.png')} />

// State variants (default/selected)
<Icon sf={{ default: "house", selected: "house.fill" }} />

Label Component

// Basic label
<Label>Home</Label>

// Hidden label (icon only)
<Label hidden>Home</Label>

Badge Component

// Numeric badge
<Badge>9+</Badge>

// Dot indicator (empty badge)
<Badge />

iOS 26 Features

Liquid Glass Tab Bar

The tab bar automatically adopts liquid glass appearance on iOS 26+.

Minimize on Scroll

<NativeTabs minimizeBehavior="onScrollDown">

Search Tab

Add a dedicated search tab that integrates with the tab bar search field:

<NativeTabs.Trigger name="(search)" role="search">
  <Label>Search</Label>
</NativeTabs.Trigger>

Note: Place search tab last for best UX.

Role Prop

Use semantic roles for special tab types:

<NativeTabs.Trigger name="search" role="search" />
<NativeTabs.Trigger name="favorites" role="favorites" />
<NativeTabs.Trigger name="more" role="more" />

Available roles: search | more | favorites | bookmarks | contacts | downloads | featured | history | mostRecent | mostViewed | recents | topRated

Customization

Tint Color

<NativeTabs tintColor="#007AFF">

Dynamic Colors (iOS)

Use DynamicColorIOS for colors that adapt to liquid glass:

import { DynamicColorIOS, Platform } from 'react-native';

const adaptiveBlue = Platform.select({
  ios: DynamicColorIOS({ light: '#007AFF', dark: '#0A84FF' }),
  default: '#007AFF',
});

<NativeTabs tintColor={adaptiveBlue}>

Conditional Tabs

Hide tabs conditionally:

<NativeTabs.Trigger name="admin" hidden={!isAdmin}>
  <Label>Admin</Label>
  <Icon sf="shield.fill" />
</NativeTabs.Trigger>

Behavior Options

<NativeTabs.Trigger
  name="home"
  disablePopToTop    // Don't pop stack when tapping active tab
  disableScrollToTop // Don't scroll to top when tapping active tab
>

Using Vector Icons

If you must use @expo/vector-icons instead of SF Symbols:

import { VectorIcon } from "expo-router/unstable-native-tabs";
import Ionicons from "@expo/vector-icons/Ionicons";

<NativeTabs.Trigger name="home">
  <VectorIcon vector={Ionicons} name="home" />
  <Label>Home</Label>
</NativeTabs.Trigger>;

Prefer SF Symbols over vector icons for native feel on Apple platforms.

Structure with Stacks

Native tabs don't render headers. Nest Stacks inside each tab for navigation headers:

// app/(tabs)/_layout.tsx
import { NativeTabs, Icon, Label } from "expo-router/unstable-native-tabs";

export default function TabLayout() {
  return (
    <NativeTabs>
      <NativeTabs.Trigger name="(home)">
        <Label>Home</Label>
        <Icon sf="house.fill" />
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}

// app/(tabs)/(home)/_layout.tsx
import Stack from "expo-router/stack";

export default function HomeStack() {
  return (
    <Stack>
      <Stack.Screen
        name="index"
        options={{ title: "Home", headerLargeTitle: true }}
      />
      <Stack.Screen name="details" options={{ title: "Details" }} />
    </Stack>
  );
}

Migration from JS Tabs

Before (JS Tabs)

import { Tabs } from "expo-router";

export default function TabLayout() {
  return (
    <Tabs>
      <Tabs.Screen
        name="index"
        options={{
          title: "Home",
          tabBarIcon: ({ color }) => (
            <IconSymbol name="house.fill" color={color} />
          ),
        }}
      />
      <Tabs.Screen
        name="settings"
        options={{
          title: "Settings",
          tabBarIcon: ({ color }) => <IconSymbol name="gear" color={color} />,
        }}
      />
    </Tabs>
  );
}

After (Native Tabs)

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" />
      </NativeTabs.Trigger>
      <NativeTabs.Trigger name="settings">
        <Label>Settings</Label>
        <Icon sf="gear" />
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}

Key Differences

JS Tabs Native Tabs
<Tabs.Screen> <NativeTabs.Trigger>
options={{ title }} <Label>Title</Label>
options={{ tabBarIcon }} <Icon sf="symbol" />
Props-based API React component-based API
tabBarBadge option <Badge> component

Migration Steps

  1. Change imports

    // Remove
    import { Tabs } from "expo-router";
    
    // Add
    import {
      NativeTabs,
      Icon,
      Label,
      Badge,
    } from "expo-router/unstable-native-tabs";
    
  2. Replace Tabs with NativeTabs

    // Before
    <Tabs screenOptions={{ ... }}>
    
    // After
    <NativeTabs>
    
  3. Convert each Screen to Trigger

    // Before
    <Tabs.Screen
      name="home"
      options={{
        title: 'Home',
        tabBarIcon: ({ color }) => <Icon name="house" color={color} />,
        tabBarBadge: 3,
      }}
    />
    
    // After
    <NativeTabs.Trigger name="home">
      <Label>Home</Label>
      <Icon sf="house.fill" />
      <Badge>3</Badge>
    </NativeTabs.Trigger>
    
  4. Move headers to nested Stack - Native tabs don't render headers

    app/
      (tabs)/
        _layout.tsx      <- NativeTabs
        (home)/
          _layout.tsx    <- Stack with headers
          index.tsx
        (settings)/
          _layout.tsx    <- Stack with headers
          index.tsx
    

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

Keyboard Handling (Android)

Configure in app.json:

{
  "expo": {
    "android": {
      "softwareKeyboardLayoutMode": "resize"
    }
  }
}

Common Issues

  1. Icons not showing on Android: Add drawable prop or use VectorIcon
  2. Headers missing: Nest a Stack inside each tab group
  3. Trigger name mismatch: Ensure name matches exact route name including parentheses
  4. Badge not visible: Badge must be a child of Trigger, not a prop