Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
ee9006d
test(react-navigation): cover the current navigator behaviour
oleksandrzavarzin-callstack Sep 10, 2026
eff21b1
feat(navigation): add @bottom-tabs/navigation
oleksandrzavarzin-callstack Sep 10, 2026
14e3e17
feat(react-navigation): delegate to @bottom-tabs/navigation
oleksandrzavarzin-callstack Sep 10, 2026
978f887
test(expo-router-test): add an expo-router integration harness
oleksandrzavarzin-callstack Sep 10, 2026
2b2268c
docs: document the standard-navigation entry for Expo Router
oleksandrzavarzin-callstack Sep 11, 2026
56c2c7f
refactor(react-navigation): drop the color dependency
oleksandrzavarzin-callstack Sep 11, 2026
3dcd9e3
fix(react-navigation): keep the full tab bar contract
oleksandrzavarzin-callstack Sep 11, 2026
67f3332
docs: correct the Expo Router warning and list the new package
oleksandrzavarzin-callstack Sep 11, 2026
dbc0727
fix(react-navigation): record the peer bump in the lockfile
oleksandrzavarzin-callstack Sep 11, 2026
b46c8bf
fix(navigation): let integrators infer the navigator props
oleksandrzavarzin-callstack Sep 11, 2026
33363a5
docs: type the shared navigator recipe
oleksandrzavarzin-callstack Sep 11, 2026
ec37666
refactor: rename the shared package to @bottom-tabs/standard-navigation
oleksandrzavarzin-callstack Sep 15, 2026
5d71839
fix: skip packages without a manifest when building the babel aliases
oleksandrzavarzin-callstack Sep 15, 2026
2cc28f1
docs: add quick-start for Expo≤55
oleksandrzavarzin-callstack Sep 16, 2026
a65309c
fix(react-navigation): restore the >=7 peer on @react-navigation/native
oleksandrzavarzin-callstack Sep 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"fixed": [
[
"react-native-bottom-tabs",
"@bottom-tabs/react-navigation"
"@bottom-tabs/react-navigation",
"@bottom-tabs/standard-navigation"
]
],
"linked": [],
Expand Down
5 changes: 5 additions & 0 deletions .changeset/drop-color-dependency.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@bottom-tabs/react-navigation': patch
---

Drop the `color` dependency in favour of React Native's `processColor`. `color` is ESM-only, so consumers had to extend `transformIgnorePatterns` before Jest would run at all
6 changes: 6 additions & 0 deletions .changeset/standard-navigation-shared-adapter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@bottom-tabs/standard-navigation': minor
'@bottom-tabs/react-navigation': minor
---

Move the native tabs implementation into a new framework-agnostic `@bottom-tabs/standard-navigation` package built on the `standard-navigation` contract, so Expo Router SDK 56+ apps can drive the same navigator through `unstable_createStandardRouterNavigator` without loading a second copy of React Navigation.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ https://github.com/user-attachments/assets/09e96ac3-827d-4ac0-add0-e7b88ee9197c
| [react-native-bottom-tabs](/packages/react-native-bottom-tabs) | [![badge](https://img.shields.io/npm/v/react-native-bottom-tabs?style=for-the-badge)](https://www.npmjs.com/package/react-native-bottom-tabs) |
| [@bottom-tabs/expo-template](/packages/expo-template) | [![badge](https://img.shields.io/npm/v/@bottom-tabs/expo-template.svg?style=for-the-badge)](https://www.npmjs.com/package/@bottom-tabs/expo-template) |
| [@bottom-tabs/react-navigation](/packages/react-navigation) | [![badge](https://img.shields.io/npm/v/@bottom-tabs/react-navigation.svg?style=for-the-badge)](https://www.npmjs.com/package/@bottom-tabs/react-navigation) |
| [@bottom-tabs/standard-navigation](/packages/standard-navigation) | [![badge](https://img.shields.io/npm/v/@bottom-tabs/standard-navigation.svg?style=for-the-badge)](https://www.npmjs.com/package/@bottom-tabs/standard-navigation) |

## Documentation

Expand Down
32 changes: 32 additions & 0 deletions apps/expo-router-test/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
const fs = require('node:fs');
const path = require('node:path');

const PROJECT_COPIES = ['react', 'react-dom'];

const reactNavigationScope = path.dirname(
path.dirname(require.resolve('@react-navigation/native/package.json'))
);

const reactNavigationCopies = fs
.readdirSync(reactNavigationScope)
.map((name) => [
`^@react-navigation/${name}($|/.*)`,
`${reactNavigationScope}/${name}$1`,
]);

module.exports = {
preset: 'jest-expo',

setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],

moduleNameMapper: {
...Object.fromEntries(
PROJECT_COPIES.map((name) => [
`^${name}($|/.*)`,
path.dirname(require.resolve(`${name}/package.json`)) + '$1',
])
),
...Object.fromEntries(reactNavigationCopies),
},

};
23 changes: 23 additions & 0 deletions apps/expo-router-test/jest.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
const consoleMethods = ['warn', 'error'] as const;

let output: string[] = [];

beforeEach(() => {
output = [];

for (const method of consoleMethods) {
jest.spyOn(console, method).mockImplementation((...args: unknown[]) => {
output.push(`console.${method}: ${args.join(' ')}`);
});
}
});

afterEach(() => {
const captured = output;

jest.restoreAllMocks();

if (captured.length > 0) {
throw new Error(`Expected no console output, got:\n${captured.join('\n')}`);
}
});
38 changes: 38 additions & 0 deletions apps/expo-router-test/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "expo-router-test",
"version": "0.0.1",
"private": true,
"scripts": {
"test": "jest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@bottom-tabs/react-navigation": "*",
"@bottom-tabs/standard-navigation": "*",
"@react-navigation/native": "^7.3.0",
"expo": "~57.0.21",
"expo-constants": "~57.0.17",
"expo-linking": "~57.0.9",
"expo-router": "~57.0.20",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.3",
"react-native-bottom-tabs": "*",
"react-native-gesture-handler": "~2.32.0",
"react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.10.1"
},
"devDependencies": {
"@react-native/jest-preset": "^0.86.3",
"@testing-library/react-native": "^14.0.1",
"@types/jest": "^29.5.5",
"@types/react": "~19.2.2",
"jest": "^29.7.0",
"jest-expo": "~57.0.5",
"test-renderer": "^1.2.0",
"typescript": "^5.9.2"
}
}
116 changes: 116 additions & 0 deletions apps/expo-router-test/src/__tests__/adapters.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { NavigationContainer } from '@react-navigation/native';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not related to this file specifically, but the example app should be named example-expo-router to follow the convention of the other example apps

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a test harness rather than the example Expo router app (for that option, we will have expo-template, after it's updated). It just checks whether our two adapters, driven by a real Expo Router and a real React Navigation, pass the same correct props to the tab view.

import { act, render } from '@testing-library/react-native';
import { renderRouter } from 'expo-router/testing-library';
import { Text } from 'react-native';

import { ExpoRouterTabs, ReactNavigationTabs } from '../tabs';

let tabViewProps: Record<string, any>;

jest.mock('react-native-bottom-tabs', () => ({
__esModule: true,
default: (props: Record<string, any>) => {
tabViewProps = props;
return null;
},
}));

const OPTIONS = {
index: { title: 'Home', tabBarBadge: '3' },
explore: { title: 'Explore' },
};

const adapters = [
{
name: 'expo-router',
render: async () => {
await renderRouter(
{
_layout: () => (
<ExpoRouterTabs>
<ExpoRouterTabs.Screen name="index" options={OPTIONS.index} />
<ExpoRouterTabs.Screen name="explore" options={OPTIONS.explore} />
</ExpoRouterTabs>
),
index: () => <Text>index scene</Text>,
explore: () => <Text>explore scene</Text>,
},
{ initialUrl: '/' }
);
},
},
{
name: 'react-navigation',
render: async () => {
await render(
<NavigationContainer>
<ReactNavigationTabs.Navigator>
<ReactNavigationTabs.Screen name="index" options={OPTIONS.index}>
{() => <Text>index scene</Text>}
</ReactNavigationTabs.Screen>
<ReactNavigationTabs.Screen
name="explore"
options={OPTIONS.explore}
>
{() => <Text>explore scene</Text>}
</ReactNavigationTabs.Screen>
</ReactNavigationTabs.Navigator>
</NavigationContainer>
);
},
},
];

beforeEach(() => {
tabViewProps = {};
});

describe.each(adapters)('$name', ({ render: renderAdapter }) => {
const routeNamed = (name: string) =>
tabViewProps.navigationState.routes.find(
(it: { name: string }) => it.name === name
);

it('drives the shared navigator', async () => {
await renderAdapter();

expect(
tabViewProps.navigationState.routes.map((it: { name: string }) => it.name)
).toEqual(['index', 'explore']);
expect(tabViewProps.navigationState.index).toBe(0);
});

it('maps screen options onto the native view', async () => {
await renderAdapter();

expect(tabViewProps.getLabelText({ route: routeNamed('index') })).toBe(
'Home'
);
expect(tabViewProps.getBadge({ route: routeNamed('index') })).toBe('3');
expect(
tabViewProps.getBadge({ route: routeNamed('explore') })
).toBeUndefined();
});

it('navigates when a tab is pressed', async () => {
await renderAdapter();

await act(async () => {
tabViewProps.onIndexChange(1);
});

expect(tabViewProps.navigationState.index).toBe(1);
});

it('exposes only the declared screens as tabs', async () => {
await renderAdapter();

expect(tabViewProps.navigationState.routes).toHaveLength(2);
});

it('does not forward router options to the native view', async () => {
await renderAdapter();

expect(tabViewProps).not.toHaveProperty('backBehavior');
});
});
47 changes: 47 additions & 0 deletions apps/expo-router-test/src/__tests__/types.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { ExpoRouterTabs } from '../tabs';

export const tintColors = () => (
<ExpoRouterTabs
tabBarActiveTintColor="#ff0000"
tabBarInactiveTintColor="#808080"
/>
);

export const defaultTintColors = () => (
<ExpoRouterTabs defaultTintColors={{ active: '#ff0000' }} />
);

export const customTabBar = () => (
<ExpoRouterTabs
tabBar={({ state }) => state.routes.map((it) => it.name).join()}
/>
);

export const nativeProps = () => (
<ExpoRouterTabs labeled sidebarAdaptable hapticFeedbackEnabled />
);

export const screenOptions = () => (
<ExpoRouterTabs>
<ExpoRouterTabs.Screen
name="index"
options={{ title: 'Home', tabBarBadge: '3' }}
/>
</ExpoRouterTabs>
);

export const rejectsAWrongNavigatorProp = () => (
// @ts-expect-error - a number is not a valid tint color
<ExpoRouterTabs tabBarActiveTintColor={42} />
);

export const rejectsAnUnknownScreenOption = () => (
<ExpoRouterTabs>
{/* @ts-expect-error - not a screen option this navigator accepts */}
<ExpoRouterTabs.Screen name="index" options={{ notARealOption: true }} />
</ExpoRouterTabs>
);

it('type checks the navigator surface', () => {
expect(typeof ExpoRouterTabs).toBe('object');
});
10 changes: 10 additions & 0 deletions apps/expo-router-test/src/tabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { NativeBottomTabsContent } from '@bottom-tabs/standard-navigation';
import { createNativeBottomTabNavigator } from '@bottom-tabs/react-navigation';
import { TabRouter, unstable_createStandardRouterNavigator } from 'expo-router';

export const ExpoRouterTabs = unstable_createStandardRouterNavigator(
NativeBottomTabsContent,
TabRouter
);

export const ReactNavigationTabs = createNativeBottomTabNavigator();
8 changes: 8 additions & 0 deletions apps/expo-router-test/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"types": ["jest"]
},
"include": ["**/*.ts", "**/*.tsx"]
}
9 changes: 4 additions & 5 deletions docs/docs/docs/getting-started/quick-start.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { PackageManagerTabs } from '@theme';

If you don't have an existing project, you can create a new [Expo](https://expo.dev) app using the following command:


```sh
npx create-expo-app@latest NativeTabs --template @bottom-tabs/expo-template
```
Expand All @@ -15,16 +14,18 @@ npx create-expo-app@latest NativeTabs --template @bottom-tabs/expo-template

<PackageManagerTabs command="install react-native-bottom-tabs" />

If you are going to use [React Navigation / Expo Router Integration](/docs/guides/usage-with-react-navigation) make sure to install `@bottom-tabs/react-navigation`.
If you are going to use the [React Navigation integration](/docs/guides/usage-with-react-navigation), or [Expo Router](/docs/guides/usage-with-expo-router) on SDK 52 to 55, also install `@bottom-tabs/react-navigation`.

<PackageManagerTabs command="install @bottom-tabs/react-navigation" />

For [Expo Router](/docs/guides/usage-with-expo-router) on SDK 56 or newer, install `@bottom-tabs/standard-navigation` instead.

<PackageManagerTabs command="install @bottom-tabs/standard-navigation" />

### Expo

Add the library plugin in your `app.json` config file and [create a new build](https://docs.expo.dev/develop/development-builds/create-a-build/).


```diff
"expo": {
+ "plugins": ["react-native-bottom-tabs"]
Expand All @@ -42,7 +43,6 @@ This library is not supported in [Expo Go](https://expo.dev/go).

Edit `android/app/src/main/res/values/styles.xml` to inherit from provided theme in order to customize the appearance of the native bottom tabs.


```diff
<resources>
- <style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
Expand All @@ -54,7 +54,6 @@ Edit `android/app/src/main/res/values/styles.xml` to inherit from provided theme

Here you can read more about [Android Native Styling](/docs/guides/android-native-styling).


### iOS with Swift Package Manager

The library ships a `Package.swift` for React Native's SwiftPM autolinking flow.
Expand Down
Loading
Loading