Shipnative
ShipnativeShipnative
Sign in
GuideAugust 2026 · 9 min read

React Native Tab Bar: Expo Router, Custom, and Floating

The tab bar is the first thing anyone sees in your app and the component most likely to be subtly wrong on one platform. A ghost tab appears because a helper file landed in the wrong folder. The Android keyboard shoves the bar into the middle of the screen. The floating pill you designed sits a few pixels too high on every iPhone. This guide covers file-based tabs in Expo Router, the options worth setting, a custom floating bar, and the safe-area mistake behind most of the layout complaints.

Four ways to build one

ApproachControlLookBest forNote
Expo Router TabsOptions onlyConsistent both platformsNearly every appFiles in (tabs) become tabs
Native tabsPlatform decidesExact system lookSystem-native feelCheck your SDK version
Custom tabBar propTotalWhatever you drawFloating bars, centre FABYou own insets and a11y
React Navigation directlyOptions onlySame as Expo RouterNon-Expo-Router appsExpo 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 button

That 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.

Frequently Asked Questions

How do I create a tab bar with Expo Router?

Create an app/(tabs) directory with a _layout.tsx that renders the Tabs navigator, then add one file per tab inside it. Expo Router derives the tab bar from the files in that directory, so a new file becomes a new tab automatically. Configure each one with a Tabs.Screen entry keyed by the file name.

How do I hide a screen from the tab bar in Expo Router?

Set href: null in that route’s options. The screen stays navigable programmatically and by URL, but no tab button is rendered. This matters because every file in the tabs directory becomes a tab by default — deleting the Tabs.Screen entry alone does not remove the button.

Why does my custom tab bar sit too high above the home indicator?

Almost always because the bottom safe-area inset is being applied twice: once by the navigator, which already pads for it, and again by your own useSafeAreaInsets call inside the custom bar. Pick one owner of that inset. When you supply a custom tabBar component, that component owns it and the navigator should not also pad.

How do I stop the Android keyboard from pushing the tab bar up?

Set tabBarHideOnKeyboard: true in screenOptions. Without it the bar rides on top of the keyboard on Android, which looks broken on any screen with a text input. It is a one-line fix that most apps ship without.

How do I add a badge to a tab?

Use tabBarBadge on that screen’s options with a number or short string, and tabBarBadgeStyle to colour it. Keep it truthy-guarded — passing 0 renders a badge showing zero, which is not what you want. Pass undefined when the count is zero.

Should I use native tabs instead of a JavaScript tab bar?

Newer Expo Router versions ship a native tabs API that renders the real platform tab bar — UITabBar on iOS, Material bottom navigation on Android — so you get exact platform behaviour and appearance for free. It is worth using when you want the system look and are not designing a custom bar. Check whether your SDK version includes it, since the API has been evolving.

→

Expo Router vs React Navigation

Tabs are one layer down from this decision — start here if you have not made it.

Read guide →
→

React Native Bottom Sheet

The other component that fights with safe-area insets.

Read guide →

Ship a real React Native app today

Describe, preview, and export Expo code — free to start.

Build with ShipNative →
ShipNative logoShipnative

Build mobile apps with AI. Describe, preview, and ship to iOS & Android in minutes.

Features

Text to App AIApp Generator from ScreenshotPRD to Mobile App

Tools

All free toolsApp Cost CalculatorApp Name GeneratorApp Store Keyword ToolReact Native Components

Blog

All blog postsHow to Build an App Without CodingBest AI Tools for Real Mobile AppsExpo EAS App Store ChecklistLovable, Cursor & v0 for MobileBest AI App Builders in 2026React Native AI App Builder Guide

Legal

FAQTerms of ServicePrivacy Policy

© 2026 ShipNative. All rights reserved.