React Native Tabs Navigation with Expo Router

Last update: 2025-06-03
Type
Quick Win's logo Quick Win
Membership
🆓
Tech
React Native React Native
React Native
TypeScript TypeScript
TypeScript
Share:

Welcome! In this quick win, we’ll learn how to add a beautiful, flexible tabs navigation to your React Native app using Expo Router and React Navigation’s Tabs. We’ll cover the basics, add custom styling, and even create our own custom tab button. Let’s get started!

Galaxies-dev/
expo-router-tabs-essentials

React Native Tabs Navigation with Expo Router
Galaxies Github Icon
Get this code Want to play around with this code locally? Download the code and see it all in context. Galaxies members get all the code & so much more. I can't access the code!

Why Tabs Navigation?

Tabs navigation is a popular pattern for mobile apps, letting users easily switch between major sections at the bottom of the screen. With Expo Router, integrating tabs is simple and powerful, leveraging React Navigation under the hood.


1. Setting Up Your Project

Let’s start by creating a new Expo app and installing the required dependencies.

Command
npx create-expo-app my-tabs
cd my-tabs
# remove the existing app
npm run reset-project

# Run Expo Go
npx expo

These packages give us everything we need for tabs navigation and smooth gestures.


2. Basic Tabs Layout

Let’s create a simple tabs layout. This is the foundation for your navigation structure.

app/_layout.tsx
import { Tabs } from 'expo-router';

export default function Layout() {
	return <Tabs />;
}

To have some dummy content, let’s add this to the index.tsx file:

app/index.tsx
import { Link } from 'expo-router';
import { Button, Image, ScrollView, Text, View } from 'react-native';

export default function Index() {
	// Example to get the height of the bottom tab bar
	// const height = useBottomTabBarHeight();

	return (
		<ScrollView>
			<Link href="/profile" push asChild>
				<Button title="Profile" />
			</Link>
			{[...Array(30)].map((_, i) => (
				<View key={i} style={{ flexDirection: 'row', alignItems: 'center', padding: 24 }}>
					<Image
						source={require('../assets/images/react-logo.png')}
						style={{ width: 32, height: 32, marginRight: 16 }}
					/>
					<Text style={{ fontSize: 18 }}>Dummy Item {i + 1}</Text>
				</View>
			))}
		</ScrollView>
	);
}

Now go ahead and simply create a new file in the app folder and add a new screen - for example, profile.tsx.

What’s happening here?

  • The <Tabs /> component from Expo Router automatically picks up your routes and displays them as tabs at the bottom.
  • You can navigate between screens using the tab bar, and each screen is just a file in your app directory!

3. Customizing Tabs Navigation

Let’s add some style and custom screens to our tabs. You can control the look and feel, icons, and more!

Here we are testing out the screenOptions to style the tabs (active color, badge, custom background, and more), and we also update each single tab individually by using its file name as the name of the tab!

app/_layout.tsx
import { HapticTab } from '@/components/HapticTab';
import { SpecialTabButton } from '@/components/SpecialTabButton';
import TabBarBackground from '@/components/TabBarBackground';
import { Ionicons } from '@expo/vector-icons';
import { Tabs } from 'expo-router';
import { Platform } from 'react-native';

export default function RootLayout() {
	return (
		<Tabs
			screenOptions={{
				tabBarActiveTintColor: '#ff00c3',
				tabBarInactiveTintColor: '#727272',
				tabBarBadgeStyle: {
					backgroundColor: '#000000',
					color: '#fff'
				},
				tabBarButton: HapticTab,
				tabBarBackground: TabBarBackground,
				tabBarStyle: Platform.select({
					ios: {
						// Use a transparent background on iOS to show the blur effect
						position: 'absolute'
					},
					default: {}
				}),
				// tabBarPosition: 'left', Moves the sidebar to the side!
				// tabBarVariant: 'material',
				animation: 'fade' // custom animation!
			}}
		>
			<Tabs.Screen
				name="index"
				options={{
					title: 'My Home',
					tabBarLabel: 'Home',
					tabBarIcon: ({ color, size }) => <Ionicons name="home" color={color} size={size} />,
					tabBarBadge: 6
				}}
			/>

			<Tabs.Screen
				name="profile"
				options={{
					title: 'My Profile',
					tabBarLabel: 'Profile',
					tabBarIcon: ({ color, size }) => <Ionicons name="person" color={color} size={size} />
				}}
			/>
		</Tabs>
	);
}

The HapticTab is a custom tab bar button that adds a soft haptic feedback when pressing down on the tabs - there is a great implementation that I’ve taken from the Expo default tabs implementation.

The TabBarBackground is a custom tab bar background that adds a blur effect to the tab bar - this is also taken from the Expo default tabs implementation.

components/HapticTab.tsx
import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
import { PlatformPressable } from '@react-navigation/elements';
import * as Haptics from 'expo-haptics';

export function HapticTab(props: BottomTabBarButtonProps) {
	return (
		<PlatformPressable
			{...props}
			onPressIn={(ev) => {
				if (process.env.EXPO_OS === 'ios') {
					// Add a soft haptic feedback when pressing down on the tabs.
					Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
				}
				props.onPressIn?.(ev);
			}}
		/>
	);
}

Please both of these files into the components folder.

components/TabBarBackground.tsx
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { BlurView } from 'expo-blur';
import { StyleSheet } from 'react-native';

export default function BlurTabBarBackground() {
	return (
		<BlurView
			// System chrome material automatically adapts to the system's theme
			// and matches the native tab bar appearance on iOS.
			tint="systemChromeMaterial"
			intensity={60}
			style={StyleSheet.absoluteFill}
		/>
	);
}

export function useBottomTabOverflow() {
	return useBottomTabBarHeight();
}

What’s new?

  • We use screenOptions to style the tabs (active color, badge, custom background, and more).
  • Each <Tabs.Screen /> defines a route, label, title, and icon.
  • The name of the screen is the name of the file!
  • You can add custom tab bar buttons and backgrounds for a unique look and feel.

4. Hidden Routes and Custom Tab Buttons

Want to add dynamic items or a custom tab button? Here’s how you can do it!

You can hide certain routes from the tab bar or add a special button for custom actions.

app/_layout.tsx
import { HapticTab } from '@/components/HapticTab';
import { SpecialTabButton } from '@/components/SpecialTabButton';
import TabBarBackground from '@/components/TabBarBackground';
import { Ionicons } from '@expo/vector-icons';
import { Tabs } from 'expo-router';
import { Platform } from 'react-native';

export default function RootLayout() {
	return (
		<Tabs
			screenOptions={{
				tabBarActiveTintColor: '#ff00c3',
				tabBarInactiveTintColor: '#727272',
				tabBarBadgeStyle: {
					backgroundColor: '#000000',
					color: '#fff'
				},
				tabBarButton: HapticTab,
				tabBarBackground: TabBarBackground,
				tabBarStyle: Platform.select({
					ios: {
						// Use a transparent background on iOS to show the blur effect
						position: 'absolute'
					},
					default: {}
				}),
				tabBarPosition: 'left',
				tabBarVariant: 'material',
				animation: 'shift'
			}}
		>
			<Tabs.Screen
				name="index"
				options={{
					title: 'My Home',
					tabBarLabel: 'Home',
					tabBarIcon: ({ color, size }) => <Ionicons name="home" color={color} size={size} />,
					tabBarBadge: 6
				}}
			/>

			<Tabs.Screen
				name="hidden"
				options={{
					href: null
				}}
			/>
			<Tabs.Screen
				name="custom"
				options={{
					title: 'Custom',
					tabBarLabel: 'Custom',
					tabBarButton: SpecialTabButton
				}}
				listeners={{
					tabPress: (e) => {
						e.preventDefault();
						console.log('tabPress');
					}
				}}
			/>
			<Tabs.Screen
				name="profile"
				options={{
					title: 'My Profile',
					tabBarLabel: 'Profile',
					tabBarIcon: ({ color, size }) => <Ionicons name="person" color={color} size={size} />
				}}
			/>
		</Tabs>
	);
}

Let’s add the special button now, which has some unique styling and a haptic feedback when pressed:

components/SpecialTabButton.tsx
import { Ionicons } from '@expo/vector-icons';
import * as Haptics from 'expo-haptics';
import { Alert, StyleSheet, TouchableOpacity } from 'react-native';

export const SpecialTabButton = () => {
	const handlePress = () => {
		Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
		Alert.alert('Special Tab Button');
	};

	return (
		<TouchableOpacity onPress={handlePress} style={styles.button} activeOpacity={0.85}>
			<Ionicons name="add-circle" size={30} color="#fff" />
		</TouchableOpacity>
	);
};

const styles = StyleSheet.create({
	button: {
		position: 'absolute',
		top: -20,
		left: '50%',
		transform: [{ translateX: -40 }],
		backgroundColor: '#4F46E5',
		borderRadius: 24,
		width: 80,
		height: 80,
		alignItems: 'center',
		justifyContent: 'center',
		boxShadow: '0px 4px 6px rgba(0, 0, 0, 0.2)'
	}
});

What’s going on?

  • You can hide a tab by setting href: null in its options.
  • Add a custom tab button for special actions, like opening a modal or triggering haptics.
  • Use listeners to intercept tab presses and run custom logic.

5. Expo Router Unstyled Tabs

Want to build your own custom tab bar UI? Expo Router provides unstyled tab primitives for full control.

app/_layout_.tsx
import { TabButton } from '@/components/TabButton';
import { TabList, Tabs, TabSlot, TabTrigger } from 'expo-router/ui';

export default function Layout() {
	return (
		<Tabs>
			<TabSlot />
			<TabList
				style={{
					backgroundColor: 'red',
					padding: 20,
					flexDirection: 'row',
					justifyContent: 'center',
					gap: 20
				}}
			>
				<TabTrigger name="home" href="/" asChild>
					<TabButton icon="home">Home</TabButton>
				</TabTrigger>
				<TabTrigger name="profile" href="/profile" asChild>
					<TabButton icon="user">Profile</TabButton>
				</TabTrigger>
			</TabList>
		</Tabs>
	);
}

You can also find more information about this way of creating tabs on the Expo blog.

Don’t forget to add the custom TabButton component to the components folder for the unsyled tabs:

components/TabButton.tsx
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { TabTriggerSlotProps } from 'expo-router/ui';
import { ComponentProps, forwardRef, Ref } from 'react';
import { Pressable, Text, View } from 'react-native';

type Icon = ComponentProps<typeof FontAwesome>['name'];

export type TabButtonProps = TabTriggerSlotProps & {
	icon?: Icon;
	ref: Ref<View>;
};

export const TabButton = forwardRef<View, TabButtonProps>(
	({ isFocused, icon, children, ...props }, ref) => {
		return (
			<Pressable
				{...props}
				style=[
					{
						display: 'flex',
						justifyContent: 'space-between',
						alignItems: 'center',
						flexDirection: 'column',
						gap: 5,
						padding: 10
					},
					isFocused ? { backgroundColor: 'green' } : undefined
				]
			>
				<FontAwesome name={icon} size={24} color={isFocused ? 'white' : '#64748B'} />
				<Text style={[{ fontSize: 16 }, isFocused ? { color: 'white' } : undefined]}>
					{children}
				</Text>
			</Pressable>
		);
	}
);

What’s this for?

  • Build a fully custom tab bar UI using Expo Router’s primitives.
  • Style and arrange your tabs however you like.

Conclusion

And that’s it! You now have a fully functional, stylish tabs navigation in your Expo Router app. You can customize it, add dynamic items, and control it from anywhere in your app. This is a great foundation for any multi-page React Native project.

Want to see the full code? Check out the repo!

Happy coding! 🚀

Galaxies-dev/
expo-router-tabs-essentials

React Native Tabs Navigation with Expo Router
Galaxies Github Icon
Get this code Want to play around with this code locally? Download the code and see it all in context. Galaxies members get all the code & so much more. I can't access the code!
Zero to Hero React Native Mission
Simon Grimm