Four ways to build one
| Approach | Control | Look | Best for | Note |
|---|---|---|---|---|
| Expo Router Tabs | Options only | Consistent both platforms | Nearly every app | Files in (tabs) become tabs |
| Native tabs | Platform decides | Exact system look | System-native feel | Check your SDK version |
| Custom tabBar prop | Total | Whatever you draw | Floating bars, centre FAB | You own insets and a11y |
| React Navigation directly | Options only | Same as Expo Router | Non-Expo-Router apps | Expo Router wraps this |
Start with the standard Tabs navigator. Its options cover more than people expect — colours, icons, badges, blur backgrounds, hiding the bar per screen — and a custom tabBar means taking ownership of insets, accessibility roles, and press feedback that you currently get for free.
The layout: files are tabs
app/
(tabs)/
_layout.tsx ← the Tabs navigator
index.tsx ← Home
search.tsx ← Search
profile.tsx ← Profile
modal.tsx ← outside (tabs), so no tab buttonThat is the rule worth internalising: every routable file inside (tabs) becomes a tab. Drop a helpers.tsx in there and you get a mystery fourth tab. Keep non-screen files outside the directory, or prefix them so they are not routes.
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
export default function TabsLayout() {
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: '#fb923c',
tabBarInactiveTintColor: '#8a8a8a',
tabBarHideOnKeyboard: true, // Android: don't ride the keyboard
tabBarStyle: {
backgroundColor: '#141414',
borderTopColor: 'rgba(255,255,255,0.06)',
},
tabBarLabelStyle: { fontSize: 11, fontWeight: '600' },
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color, size }) => (
<Ionicons name="home" color={color} size={size} />
),
}}
/>
<Tabs.Screen
name="search"
options={{
title: 'Search',
tabBarIcon: ({ color, size }) => (
<Ionicons name="search" color={color} size={size} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ color, size }) => (
<Ionicons name="person" color={color} size={size} />
),
// undefined, not 0 — passing 0 renders a badge showing "0"
tabBarBadge: unread > 0 ? unread : undefined,
}}
/>
{/* a screen that lives here but should not have a button */}
<Tabs.Screen name="onboarding" options={{ href: null }} />
</Tabs>
);
}href: nullis the answer to “how do I remove this tab”. Removing the Tabs.Screen entry does not remove the button, because the button came from the file, not from the entry — the entry only configures it.
The safe-area trap
On a device with a home indicator there is a bottom inset — roughly 34 points on most iPhones — that something has to pad for. The bug is that several things pad for it. The navigator adds it. A SafeAreaView somewhere up the tree adds it. Your custom bar calls useSafeAreaInsets and adds it again. The result is a bar floating far too high, and because it renders correctly on Android and on older iPhones, it survives review.
Pick one owner. If you pass a custom tabBar, that component owns the inset — and then no ancestor should be applying bottom safe-area padding around it:
// ❌ inset applied twice — bar sits ~34pt too high
<SafeAreaView edges={['bottom']}>
<View style={{ paddingBottom: insets.bottom }}>…</View>
</SafeAreaView>
// ✅ one owner
<SafeAreaView edges={['top']}> {/* top only */}
<MyTabBar /> {/* handles bottom itself */}
</SafeAreaView>The related trap: an absolutely positioned tab bar does not reserve space, so the last row of every list hides behind it. Pad your scroll content by the bar height — useBottomTabBarHeight() from bottom-tabs gives you the real number including the inset, which beats a hard-coded 80.
A floating pill bar
When the design calls for a rounded bar hovering above the content, supply your own tabBar. React Navigation hands you the navigation state, the per-screen descriptors, and the navigation object — everything the default bar uses:
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Pressable, StyleSheet, Text, View } from 'react-native';
function FloatingTabBar({ state, descriptors, navigation }) {
const insets = useSafeAreaInsets(); // the ONLY place bottom is applied
return (
<View style={[styles.wrap, { bottom: insets.bottom + 12 }]}>
{state.routes.map((route, index) => {
const { options } = descriptors[route.key];
if (options.href === null) return null; // respect hidden routes
const focused = state.index === index;
const onPress = () => {
const event = navigation.emit({
type: 'tabPress', target: route.key, canPreventDefault: true,
});
if (!focused && !event.defaultPrevented) navigation.navigate(route.name);
};
return (
<Pressable
key={route.key}
onPress={onPress}
accessibilityRole="button"
accessibilityState={{ selected: focused }}
accessibilityLabel={options.tabBarAccessibilityLabel}
style={styles.item}
>
{options.tabBarIcon?.({
color: focused ? '#fb923c' : '#8a8a8a',
size: 22,
focused,
})}
<Text style={[styles.label, focused && { color: '#fb923c' }]}>
{options.title ?? route.name}
</Text>
</Pressable>
);
})}
</View>
);
}
// then: <Tabs tabBar={(props) => <FloatingTabBar {...props} />}>Two lines there are easy to skip and cost you real behaviour. Emitting tabPress rather than calling navigate directly is what makes tapping the active tab scroll a list back to the top. And the accessibilityRole and accessibilityStateprops are what a screen reader uses to announce “Home, selected” — the default bar sets them, and a custom bar that forgets them is a genuine regression for some of your users.
Three or four tabs, not six
The technical part is an afternoon. The part that decides whether the app feels considered is how many tabs there are. A tab bar is a claim about the top-level structure of your product, and five or six entries usually means the structure has not been decided — the bar becomes a menu, labels shrink to fit, and every new feature triggers another argument about where it goes. Three or four, with the rest reached from inside a section, ages better.
That is easier to judge on a phone than in a mockup. Describe the shape — “a four-tab app with Home, Search, Saved, and Profile, and a floating pill tab bar” — and ShipNative generates it as real React Native with Expo Router, running on your device, so you can hold it and see whether the fifth tab is missed. If you have not chosen a router yet, start with Expo Router vs React Navigation.