
Native Tabs in Expo Router: The iOS 26 Liquid Glass Era
Why Native Tabs Matter
If you've ever tried to perfectly replicate the iOS tab bar in React Native, you know the pain. The blur effects, the animations, the dynamic island interactions. JavaScript just can't match native behavior.
With SDK 54+, Expo Router introduced NativeTabs. Unlike the JavaScript <Tabs /> component, this renders a real native tab bar. On iOS, you get the system UITabBarController. On Android, Material Tabs.
And with iOS 26, Apple introduced "Liquid Glass", the translucent, context-aware UI layer that adapts to your content. Native tabs get this for free. This guide is based on the official Expo documentation.
Getting Started
First, make sure you're on Expo SDK 54 or later. The API lives in an unstable import (for now):
import { NativeTabs } from 'expo-router/unstable-native-tabs';Here's the minimal file structure:
app/
├── _layout.tsx
├── index.tsx
└── settings.tsxAnd the layout file:
// 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>
);
}That's it. Two tabs, native rendering, SF Symbols on iOS, Material icons on Android.
The Trigger API
Unlike JavaScript tabs where routes are automatically added, NativeTabs uses Triggers. You explicitly declare which routes appear in the tab bar.
This gives you control:
// Hide a tab conditionally
<NativeTabs.Trigger name="admin" hidden={!isAdmin} />Note: Tabs aren't added automatically. If a route exists but has no Trigger, it won't appear in the tab bar.
Icons: SF Symbols, Material, and Custom
The Icon component is flexible. You can use:
Platform Symbols
<NativeTabs.Trigger.Icon
sf="house.fill" // iOS SF Symbol
md="home" // Android Material icon
/>Selected/Unselected States (iOS)
<NativeTabs.Trigger.Icon
sf={{ default: 'house', selected: 'house.fill' }}
md="home"
/>Custom Images
<NativeTabs.Trigger.Icon
src={require('../assets/custom-icon.png')}
/>For multi-color icons, set renderingMode="original" to preserve colors instead of applying the tint.
Badges
Adding notification badges is straightforward:
<NativeTabs.Trigger name="messages">
<NativeTabs.Trigger.Badge>9+</NativeTabs.Trigger.Badge>
<NativeTabs.Trigger.Label>Messages</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>Pass no children for a simple dot indicator:
<NativeTabs.Trigger.Badge /> {/* Just a dot */}Hiding the Tab Bar Dynamically
Sometimes you need to hide the tab bar for specific screens (video players, immersive content). Use a Context:
// context/TabBarContext.tsx
import { createContext } from 'react';
export const TabBarContext = createContext<{
setIsTabBarHidden: (hidden: boolean) => void;
}>({ setIsTabBarHidden: () => {} });// app/_layout.tsx
import { useState } from 'react';
import { NativeTabs } from 'expo-router/unstable-native-tabs';
import { TabBarContext } from '../context/TabBarContext';
export default function TabLayout() {
const [isTabBarHidden, setIsTabBarHidden] = useState(false);
return (
<TabBarContext value={{ setIsTabBarHidden }}>
<NativeTabs hidden={isTabBarHidden}>
{/* triggers */}
</NativeTabs>
</TabBarContext>
);
}Then in any screen:
import { useFocusEffect } from 'expo-router';
import { use } from 'react';
import { TabBarContext } from '../context/TabBarContext';
export default function VideoPlayer() {
const { setIsTabBarHidden } = use(TabBarContext);
useFocusEffect(() => {
setIsTabBarHidden(true);
return () => setIsTabBarHidden(false);
});
return <Video />;
}iOS 26 Features
If you're compiling with Xcode 26+, you get access to new features:
Separate Search Tab
<NativeTabs.Trigger name="search" role="search">
<NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>Minimize on Scroll
The tab bar can shrink when scrolling:
<NativeTabs minimizeBehavior="onScrollDown">
{/* triggers */}
</NativeTabs>Bottom Accessory (Mini Player)
For persistent controls like a music player:
<NativeTabs>
<NativeTabs.BottomAccessory>
<MiniPlayer />
</NativeTabs.BottomAccessory>
{/* triggers */}
</NativeTabs>Common Gotchas
Transparent Tab Bar on iOS 18
If your tab bar becomes transparent when scrolling, add:
<NativeTabs.Trigger name="index" disableTransparentOnScrollEdge>White Flash When Switching Tabs (iOS 26)
Wrap your app in a ThemeProvider:
import { ThemeProvider, DarkTheme } from '@react-navigation/native';
<ThemeProvider value={DarkTheme}>
<NativeTabs>{/* tabs */}</NativeTabs>
</ThemeProvider>Scroll-to-Top Not Working
Make sure ScrollView is the first child of your screen, or set collapsable={false} on wrapper Views.
Migration from JavaScript Tabs
| JavaScript Tabs | Native Tabs |
|---|---|
Tabs.Screen component | NativeTabs.Trigger component |
options prop with tabBarIcon | NativeTabs.Trigger.Icon child |
| Automatic route discovery | Explicit Trigger declaration |
| Full customization | Platform-constrained |
Known Limitations
- 5 tab maximum on Android (Material constraint)
- No nested native tabs (use JS tabs for nesting)
- FlatList issues with scroll detection
- No dynamic tab add/remove (tabs should be static)
Native tabs aren't a drop-in replacement. They're a paradigm shift toward trusting the platform. If you want buttery-smooth, system-native navigation that automatically benefits from OS updates like Liquid Glass, this is the path forward.
For more customization, stick with JavaScript tabs. For native polish, go native.


