You replace a React Native tab bar with one prop. The hard part is the bottom of an Android screen.
A custom bottom navigation bar is a supported feature in both routers, and you do not draw it over the top of the navigator. You give the navigator your component and it stops drawing one of its own. In React Navigation that is the tabBar prop. In Expo Router there is also a headless set of pieces, Tabs, TabList, TabTrigger and TabSlot, imported from expo-router/ui, which lets the bar be ordinary views you wrote. Neither route is a hack, and neither one needs you to fight the navigator. If you have not picked a router yet, the routing choices comparison is the shorter read.
What costs people a day is the bottom edge of an Android phone. React Native's own SafeAreaView is deprecated and applies to iOS only. Edge to edge is now forced on recent Android. And the amount of room at the bottom is different under gesture navigation than under three buttons, on the same phone, because it is a setting. Every component name, command and version number below comes from Expo's, React Navigation's, Reanimated's and Android's own documentation, read on 10 October 2026.
Start hereThe short version
Hand the navigator a component, then pay the insets.
The work splits in two, and only one half is about design. Half one is replacing the bar, which is a prop or a handful of components and takes an afternoon. Half two is the bottom inset, which is twenty lines and takes a day, because the thing that tells you it is wrong is a physical Android phone in the right navigation mode.
Do them in that order and nothing surprises you. Build the bar from the navigator's own extension point. Read the safe area inset at runtime rather than typing a number. Animate with Reanimated, which Expo installs for you with one command but does not put in a project by default. Then look at the result on a device with gesture navigation turned on, and again with three buttons turned on.
How do you replace a tab bar instead of hacking around it?
Both routers give you an extension point, so the answer is never an absolutely positioned view on top of the real bar. React Navigation's bottom tab navigator takes a tabBar prop. Its API reference describes it as a function that returns a React element to display as the tab bar, and it is called with three things: state, the state object for the tab navigator, descriptors, the options for each route, and navigation. You read the active index out of state, you read titles and icons out of descriptors, and you call navigation yourself when somebody presses a button. For smaller changes there are tabBarStyle and tabBarButton, which leave the stock bar in place.
Expo Router has a second route, and it is the better one when the bar is not a single block at the bottom. Four components come from expo-router/ui. Tabs is the wrapper. TabList is the container view for the list of triggers. TabTrigger switches to a tab, and inside a TabList it needs both a name, which you choose, and an href, the route it points at. TabSlot renders whichever tab is selected. There is no stock bar to override here, because there was never one: you are assembling the parts.
Two props carry most of the custom work. asChild lets a TabTrigger forward its props to its immediate child, so your own button component becomes the trigger instead of sitting inside one. And a TabTrigger forwards an isFocused prop to that child, which is how a custom button knows to look active. Those two together are what let a tab be a pill, an icon that grows, or a bar that slides. Expo also documents the awkward cases, which is worth reading before you plan a layout: a TabTrigger outside the TabList cannot take an href and instead matches an in list trigger by name, ambiguous routes in shared groups need the group spelled out in the href, and TabSlot cannot be nested inside TabList.
Before you build any of it, cut the number of tabs. Five is usually one too many, and the fifth is usually something nobody opens twice a week. Destinations like a settings screen belong behind a profile tab, not in the bar. A custom bar makes every extra tab more expensive, because you are now the one deciding how four icons and four labels fit a 320 point wide phone with the font scale turned up.
Which animation library should the bar use?
React Native Reanimated, and here the question's premise needs correcting. There is no animation library sitting in an Expo project by default. Expo's own Reanimated page gives the install command as npx expo install react-native-reanimated react-native-worklets, and it is explicit that no additional configuration is required, because the Reanimated Babel plugin is configured automatically in babel-preset-expo when you install the library. So Reanimated is the default choice, not a default dependency. The SDK pins the version for you: Expo SDK 57, the current release, ships Reanimated 4.5 and react-native-worklets 0.10.
The second package in that command is new enough to catch people out. Reanimated's documentation says react-native-worklets was separated from react-native-reanimated for better modularity and must be installed separately. Install only the first half of the command and the app fails at runtime rather than at build time, which is a confusing way to learn this. There is a harder constraint behind it as well. Reanimated 4 works only with the React Native New Architecture, and an app still on the old architecture has to use Reanimated 3, which the same page describes as no longer actively maintained. Check which architecture your project builds with before you design the animation.
What you animate is simpler than people expect. One value, the active index, drives everything: a pill that slides under the selected icon, an icon that scales, a label that fades in. With Expo Router's headless tabs you get that state handed to you, because a TabTrigger forwards isFocused to your own button. With React Navigation you read it out of the state object. Either way the animation reads one boolean per button and runs off the JavaScript thread, which is the whole reason to reach for Reanimated rather than the Animated API that ships with React Native.
Keep the animation cheap. A bar sits on top of every screen in the app, so anything expensive inside it is expensive everywhere. Avoid animating layout properties, keep heavy children out of the bar, and do not let the bar re render on every keystroke in a search field two levels down. If tab switches feel heavy, the bar is rarely the cause and app performance covers how to find out what is.
Expo, custom tabs in Expo Router, on asChild and isFocused, read 10 October 2026
Check yours
The Android bottom edge check
Tick what your bar does. Each one is a thing that looks fine in a simulator and goes wrong on a real phone.
Nothing ticked. If the bar is the stock one and you have changed only colours and icons, the navigator is still doing this work for you and none of the items above apply yet.
What breaks at the bottom of an Android screen?
Start with the one that is pure bad luck. React Native ships a SafeAreaView component, it is the obvious thing to wrap a bar in, and it is the wrong one. Its documentation marks it deprecated, says to use react-native-safe-area-context instead, and states that it is currently only applicable to iOS devices with iOS version 11 or later. That is the whole bug: correct padding on iOS, no padding at all on Android, from a component whose name says it handles exactly this. Expo recommends react-native-safe-area-context, which gives you SafeAreaProvider, its own SafeAreaView and the useSafeAreaInsets hook, and which Expo Router already installs as a peer dependency.
The reason the padding matters more than it used to is that the window now goes behind the system bars whether you asked for it or not. Android enforces edge to edge on Android 15, API level 35, and higher once your app targets SDK 35. Expo SDK 54 moved to targeting Android 16, API 36, and its changelog says edge to edge will be enabled in all Android apps and cannot be disabled. The result Android illustrates in its own guidance is a floating action button partly obscured by the navigation bar, which is the same geometry as a tab bar with a raised centre button. Opting out is not on the table any more, so the insets are the only answer.
Then the part nobody predicts: the bottom is not one height. Android exposes insets by type, including WindowInsetsCompat.Type.systemBars, which it calls the best choice for tappable views that must not be obscured, WindowInsetsCompat.Type.displayCutout, and WindowInsetsCompat.Type.systemGestures, the areas where system gestures take priority over your app. On Android 10 or later those gesture insets contain a bottom inset for the home gesture, plus a left and a right inset for the back gestures. A tab inside the bottom strip still answers a tap, but a drag starting there goes to the system. A swipe handler on the bar loses its outer pixels on both sides.
The two navigation modes also differ in how they are drawn, not just in how much room they take. With three button navigation Android applies a translucent scrim to the navigation bar by default, and native code turns that off with window.setNavigationBarContrastEnforced set to false. In an Expo project the equivalent is the androidNavigationBar.enforceContrast property in app.json, which decides whether the navigation bar is fully transparent or semi opaque against your content. A bar designed against a transparent navigation area can look muddy the moment a user switches to three buttons, which is a setting and not a device.
What do you check before you ship it?
Four passes, in this order, and the first two are free. Pass one: turn the phone's navigation mode from gestures to three buttons and back, with the app open. Nothing should move except the amount of space under the bar. Pass two: turn the system font scale up to its largest setting. Labels in a custom bar are your problem now, and four of them at the largest scale is where a hand built bar usually breaks first. Both of these are settings on a device you already own, so there is no excuse for finding out later.
Pass three is touch targets. The stock bar gives you a sensible height for free and your version does not. Measure the tappable area rather than the icon, keep the whole button pressable instead of just the glyph, and keep every target clear of the bottom gesture strip. The rule of thumb that holds across both platforms is that a control small enough to need a careful thumb is too small, and the places people get this wrong are the raised centre button and the label underneath an icon. The wider rules for spacing, contrast and hit area are in app design basics, and they apply to a bar you built exactly as they apply to one you configured.
Pass four is the keyboard and the odd screen. Open a screen with a text field in it and check where the bar goes when the keyboard comes up. Open a screen that scrolls to the very bottom and check that the last row is not hidden behind the bar, because content padding is a separate thing from bar padding and replacing the bar is how people lose it. Then check a screen that should have no bar at all, like a modal or a camera view, because a custom bar is easier to accidentally render everywhere than a configured one.
One last thing that is not about the bar. Android treats the display cutout as its own inset type, separate from the system bars, and a phone held in landscape puts that cutout down one side. If your app rotates, look at the bar in landscape once. It is a ten second check that catches a layout nobody tested, and it is the only reason the cutout inset is worth knowing about for a component that lives at the bottom of the screen.
Android, display content edge to edge, on inset types, read 10 October 2026
Three levels of custom, and what each one buys you
| What you want | Tab options only | The tabBar prop | Headless expo-router/ui |
|---|---|---|---|
| Different colours, icons and labels | Yes | Yes | Yes |
| A shape the stock bar cannot make | No | Yes | Yes |
| You own focus and press handling | No | Yes, from state and navigation | Yes, from TabTrigger |
| A trigger outside the bar, such as in a header | No | No | Yes |
| More than one bar in the same app | No | No | Yes |
| What you actually write | Option values | One component | A component plus the routing pieces |
Where this lands if a builder generated your app
A generated app does not change any of the above, because the bar is still a React Native component in a project you can open. What changes is how fast you get to the first version of it, and whether you can edit the result once you have one.
Newly is an AI app builder. You describe an app in plain English and it writes a real React Native and Expo project that you own, with two way GitHub sync, so a custom bar is a file you can open and change by hand afterwards. It uploads iOS builds to TestFlight, publishes to Google Play internal testing and builds a standalone Android APK, which matters here more than usual: the bottom inset bug does not show up in a simulator, and internal testing is the cheapest way to get the app onto a real Android phone with gesture navigation on. It is $25 a month and there is no free tier.
The honest limit is the one Apple sets, not the tool. Newly does not submit to the App Store on your behalf, because Apple requires the owner of the content to submit the app. So you keep your own developer account and press the button yourself. For the bar specifically, the thing to ask for is the behaviour rather than the pixels: say which tabs exist, what the active one should look like, and that the bar must respect the bottom safe area inset. That last clause is the one people leave out, and it is the one that costs a day.
Questions people ask about custom bottom navigation
You hand the navigator your own component instead of drawing over the top of it. React Navigation's bottom tab navigator takes a tabBar prop, described in its API reference as a function that returns a React element to display as the tab bar, and it is called with state, descriptors and navigation. Expo Router adds a headless set of pieces in expo-router/ui: Tabs, TabList, TabTrigger and TabSlot. Both are documented paths, so a custom bar is a supported feature rather than a workaround.
Describe the bar, then go and look at the bottom edge
Name the tabs, say what the active one does, and ask for the bottom safe area inset in the same sentence. Then put the build on a real Android phone and switch the navigation mode while it is open.
Start building