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.
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
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>>
Tab takes what the tab shows, one argument, or none:
| Content | The 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 layout | That 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>
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>
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>
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>
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>
A dot at the top of the icon: a badge without text, for "something new" without a count.
dot()ordot(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>
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>
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>
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>
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>
On by default. A tap on the selected tab:
| The user is | The tap |
|---|---|
| Deep in the tab: Inbox → Chat → Profile | Goes back to Inbox, the first screen of the tab. |
| On the first screen, scrolled down | Scrolls 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>
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>
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>
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>
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>
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>
Methods for Android only; on iOS the function isn't called. See ios() above.
Addressing the tab
id()
id(id: string): TabElement<Uses>
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
Tabs,useTabPress, the options.- Tabs: selecting a tab, presses, badges, the tab bar on each platform.