Skip to main content
‹ Tabs
Function

Tab()

A tab with its content: a screen by name, Screen(…), or a layout. Without content the tab is a button: it never becomes current, and onPress decides what it does, such as a New tab that opens a modal.

SectionTabsDraftDocumented, not reviewed yet: the API can change.
Import
import { Tab } from '@apexnative/navigation';

Tab is one tab of Tabs: what the tab shows, and its item in the tab bar, its label, icon and badge, with what a tap on it does. You write it only inside Tabs(…).

Usage​

src/Navigation.ts
import {
createNavigation,
navigation,
Tab,
Tabs,
} from '@apexnative/navigation';
import { HomeScreen } from './screens/Home';
import { InboxScreen } from './screens/Inbox';
import { ComposeScreen } from './screens/Compose';

export const Navigation = createNavigation({
screens: {
Home: HomeScreen,
Inbox: InboxScreen,
Compose: ComposeScreen,
},
root: Tabs(
Tab('Home').label('Home').icon(require('./icons/home.png')),
Tab()
.label('New')
.icon(require('./icons/plus.png'))
.onPress(() => navigation.showModal('Compose')),
Tab('Inbox')
.label('Inbox')
.icon(require('./icons/inbox.png'))
.id('inbox')
.badge(3),
),
});

The bar shows three items. Home and Inbox are tabs, each with a stack; Inbox has a badge with 3. New is a button: a tap opens Compose in a modal, and the tab the user was on stays selected.

Signature​

function Tab(): TabElement<never>
function Tab<const Content extends LayoutContent>(
content: Content
): TabElement<UsesOf<Content>>
Defined in src/api/layout/Tabs.ts ↗

Tab takes what the tab shows, one argument, or none:

ContentThe tab shows
'Inbox'The screen in a stack of its own: the usual tab.
Stack('Inbox', Screen('Chat', { userId }))That stack: the tab opens with Chat over Inbox.
SideMenu(…), SplitView(…), another layoutThat layout.
Screen('Inbox', { folder })The screen alone, without a stack: no top bar, no push.
Nothing: Tab()Nothing: the tab is a button.

It returns a TabElement. Each method returns a new one, so a tab can be kept in a constant and given to several Tabs.

What a tab holds​

A screen named alone gets a stack around it, so the screen has a top bar and pushes into its own tab. A screen with props needs the stack written out, because Screen(…) alone is a screen without a stack:

// Don't: a tab without a top bar, which can't push
Tab(Screen('Inbox', { folder: 'work' }));

// The tab with its stack
Tab(Stack(Screen('Inbox', { folder: 'work' })));

The screens are checked by the types where the tabs are used (root, roots, switchTo).

A tab that is a button​

Tab() without content is a button in the tab bar: it never becomes the selected tab, and its onPress() decides what a tap does, such as a New item that opens a modal to write. It takes the methods of the item (label, icon, badge); selectOnPress() and the methods of a stack (popToRootOnPress, popToRootOnBlur) mean nothing for it.

The item​

label()​

label(label: string): TabElement<Uses>
PlatformsiOSiPadOSAndroid

The text of the tab, under its icon.

Tab('Inbox').label('Inbox');

Whether the label shows is the tab bar's titleDisplayMode: on Android, by default, labels show with up to three tabs, and only the selected tab's from four, as Material 3 does.

icon()​

icon(icon: ImageResource): TabElement<Uses>
PlatformsiOSiPadOSAndroid

The icon of the tab. Two forms:

  • An image: require('./icons/home.png'), the same on both platforms.
  • A symbol of each platform: { ios: 'house', android: 'ic_home' }, an SF Symbol on iOS and a drawable of the app (res/drawable) on Android. A platform left out shows no icon.
Tab('Home').icon({ ios: 'house', android: 'ic_home' });

On iOS 18+ the tab bar draws an SF Symbol filled and in the colors of the tab's state, as the system's apps do; a bitmap takes iconColor and selectedIconColor.

selectedIcon()​

selectedIcon(selectedIcon: ImageResource): TabElement<Uses>
PlatformsiOSiPadOSAndroid (not supported)

The icon while the tab is selected; without it the tab keeps its icon. It takes the same forms as icon().

Tab('Home')
.icon(require('./icons/home.png'))
.selectedIcon(require('./icons/home-filled.png'));

Both platforms show it. On iOS 18 to 26.0 the tab bar doesn't: the system tab API of iOS 18 has a selected image only from iOS 26.1.

badge()​

badge(badge: string | number | null): TabElement<Uses>
PlatformsiOSiPadOSAndroid
iOS
Android

A number or a short text on the icon: unread messages, new items. null and the number 0 show no badge, so a count goes as it is; the text '0' shows "0".

Tab('Inbox').id('inbox').badge(3);

This is the badge the tab starts with. The app changes it from anywhere with navigation.setTabBadge(count, 'inbox'), and a screen of the tab with setTabBadge(count); both read 0 the same way. See Badges and dots.

dot()​

dot(dot: boolean | DotIndicatorOptions = true): TabElement<Uses>
PlatformsiOSiPadOSAndroid

A dot at the top of the icon: a badge without text, for "something new" without a count.

  • dot() or dot(true): shows it;
  • dot(false): hides it;
  • dot({ color, size }): shows it with a color and a size. The color is red on iOS and the theme's error color on Android by default; size (6 by default) applies on iOS before 26 only, as Android and iOS 26 draw the dot in the system's size.
Tab('Profile').dot({ color: '#FF9500' });

A badge takes its place while the tab has one. iOS 26 draws the dot as an empty badge: in a badge's size, colored only on the selected tab. The iPad tab bar at the top shows no dot: use a badge there.

itemStyle()​

itemStyle(style: TabItemStyle): TabElement<Uses>
PlatformsiOSiPadOSAndroid

The look of this tab's item: colors of the icon and the text, selected and not, the font, the icon's size and insets. It goes over the itemStyle() of Tabs, key by key, so a tab changes only what is its own:

Tabs(
Tab('Home'),
Tab('Live').itemStyle({ selectedIconColor: '#FF3B30' }),
).itemStyle({ selectedIconColor: '#0A84FF' });

Live turns red when selected; Home turns blue. The platforms read some keys only: see the options.

role()​

role(role: NonNullable<OptionsBottomTab['role']>): TabElement<Uses>
PlatformsiOSiPadOSAndroid (not supported)

iOS. A tab of the system, with its look and place:

  • 'search': on iOS 26 a separate Liquid Glass button at the end of the bar; selected, the bar gives its place to the search field. One per tab bar; a second one is a normal tab.
  • The others ('favorites', 'history', 'more'…): before iOS 18, the system's icon and title. On iOS 18+ they show the tab's own label and icon.
Tab('Search').role('search');

The search tab keeps the system's title and image unless label() or icon() sets them. Android shows a normal tab.

testID()​

testID(testID: string): TabElement<Uses>
PlatformsiOSiPadOSAndroid

The id end-to-end tests find the tab item by, such as Detox's by.id('inbox-tab').

Tab('Inbox').testID('inbox-tab');

options()​

options(options: OptionsBottomTab): TabElement<Uses>
PlatformsiOSiPadOSAndroid

The bottomTab options no method covers: badgeColor, animateBadge on Android, disableIconTint, a component of your own for the item (component, see customRow()).

Tab('Inbox').badge(3).options({ badgeColor: '#FF3B30' });

These are the options of the tab's item, not of its screens: the screens' options go on the screens. Every key is in the options.

A tap on the tab​

A second tap on the selected tab is a convention of both platforms: back to the start of the tab. These methods change what a tap does.

popToRootOnPress()​

popToRootOnPress(popToRootOnPress = true): TabElement<Uses>
PlatformsiOSiPadOSAndroid
iOS
Android

On by default. A tap on the selected tab:

The user isThe tap
Deep in the tab: Inbox → Chat → ProfileGoes back to Inbox, the first screen of the tab.
On the first screen, scrolled downScrolls its list back to the top.

popToRootOnPress(false) turns both off, such as for a tab where going back would lose a form:

Tab('Compose').popToRootOnPress(false);

The scroll goes to the first scroll view the navigation finds in the screen, such as a FlatList or ScrollView filling it.

popToRootOnBlur()​

popToRootOnBlur(popToRootOnBlur = true): TabElement<Uses>
PlatformsiOSiPadOSAndroid

When the user leaves the tab, its stack goes back to its first screen, so the tab always opens at its start.

Tab('Search').popToRootOnBlur();

It happens out of sight: nothing animates, and the screens taken off send no appear or disappear events. They unmount. Off by default: the tab keeps its stack.

selectOnPress()​

selectOnPress(selectTabOnPress: boolean): TabElement<Uses>
PlatformsiOSiPadOSAndroid

false: a tap doesn't select the tab. The current tab stays, the tap only sends its events, and onPress() decides. For a tab that sometimes acts as a button, such as a Profile tab that asks to sign in first:

Tab('Profile')
.selectOnPress(false)
.onPress(async () => {
if (navigation.getContext().signedIn) {
await navigation.selectTab('profile');
} else {
await navigation.showModal('Login');
}
})
.id('profile');

selectTab('profile') from code still selects it. A button tab has this set already.

onPress()​

onPress(handler: TabPressHandler): TabElement<Uses>
PlatformsiOSiPadOSAndroid

Called on every tap on the tab, a second one too, with { index, wasSelected, screenId }:

  • index: the tab's place, from 0;
  • wasSelected: the tab was selected already: a second tap;
  • screenId: the screen on top of the tab's stack.
Tab('Inbox').onPress(({ wasSelected }) => {
if (wasSelected) analytics.track('inbox_retap'); // your own
});

It runs after the native side has acted on the tap: the tab is selected, or with popToRootOnPress its stack went back. It runs outside React, so it uses the app's navigation, not a hook. A screen that wants the taps on its own tab, such as to refetch, uses useTabPress instead. The handler lives as long as the tabs are shown.

iPad​

customization()​

customization(
customization: NonNullable<OptionsBottomTab['customization']>,
more: Pick<OptionsBottomTab, 'shownByDefault' | 'allowsHiding'> = {}
): TabElement<Uses>
PlatformsiOS 18+iPadOS 18+Android (not supported)

iPadOS 18+, with Tabs().customizable(). What the user can do with this tab when they arrange the tabs:

  • 'fixed': stays in its place in the bar;
  • 'movable': can be moved, not removed (the default);
  • 'optional': can be moved, removed and added back from the sidebar;
  • 'pinned': stays at the trailing end of the bar;
  • 'sidebarOnly': is in the sidebar only.

The second argument: shownByDefault: false keeps an 'optional' tab in the sidebar until the user adds it to the bar; allowsHiding: true lets the user hide the tab.

Tab('Archive')
.id('archive')
.customization('optional', { shownByDefault: false });

Read when the tabs are created. The search tab (role('search')) is 'pinned' unless set.

One platform only​

ios()​

ios(build: (tab: TabElement<Uses>) => TabElement<Uses>): TabElement<Uses>
PlatformsiOSiPadOSAndroid

Methods for iOS only; on Android the function isn't called. For a value that differs by platform:

Tab('Search')
.label('Search')
.ios((tab) => tab.role('search'))
.android((tab) => tab.icon(require('./icons/search.png')));

android()​

android(build: (tab: TabElement<Uses>) => TabElement<Uses>): TabElement<Uses>
PlatformsiOSiPadOSAndroid

Methods for Android only; on iOS the function isn't called. See ios() above.

Addressing the tab​

id()​

id(id: string): TabElement<Uses>
PlatformsiOSiPadOSAndroid

A fixed id for the tab. The commands and methods that name a tab take it:

Tabs(
Tab('Home').id('home'),
Tab('Inbox').id('inbox'),
).selected('inbox');

// later, from anywhere
await navigation.selectTab('inbox');
await navigation.setTabBadge(5, 'inbox');

An index works too, but an id keeps working when the tabs change order, and iPad customization needs ids. The id goes to the tab's layout, the stack around its screen, and is unique in the app.

See also​

Generated from the types and their comments. Edit the source ↗