Skip to main content

Tabs

Tabs organize content into groups the user switches between without leaving the screen. Follows the Material Design 3 Tabs specification.

There are two variants. Primary tabs sit directly under an app bar and mark the active tab with a short indicator matched to its label. Secondary tabs sit inside content — often under a primary row — and mark the active tab with a full-width indicator.

Tabs is a bar, not a navigator

Tabs renders the row and tells you which tab was pressed. It does not own screens, mount panels, or integrate with a navigator. Point it at whatever you already use — useState for a panel on one screen, or your router's state for real routes. See Wiring it to a navigator.

Usage

Pass the tabs as items and read the selection from onValueChange. With no value prop, Tabs tracks the active tab itself and starts on the first item.

import { Tabs } from '@rootnative/components'
import { ThemeProvider } from '@rootnative/core'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { useState } from 'react'
import { Text, View } from 'react-native'

const SECTIONS = [
{ value: 'flights', label: 'Flights' },
{ value: 'trips', label: 'Trips' },
{ value: 'explore', label: 'Explore' },
]

const PANELS = {
flights: 'Two seats left at this price.',
trips: 'Nothing booked yet.',
explore: 'Places you might like.',
}

function Screen() {
const [section, setSection] = useState('flights')
return (
<View style={{ flex: 1 }}>
<Tabs items={SECTIONS} value={section} onValueChange={setSection} />
<View style={{ padding: 24 }}>
<Text>{PANELS[section]}</Text>
</View>
</View>
)
}

export default function App() {
return (
<SafeAreaProvider>
<ThemeProvider>
<Screen />
</ThemeProvider>
</SafeAreaProvider>
)
}

Variants

import { Tabs } from '@rootnative/components'
import { ThemeProvider } from '@rootnative/core'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { View } from 'react-native'

const TRAVEL = [
{ value: 'flights', label: 'Flights', icon: 'airplane' },
{ value: 'hotels', label: 'Hotels', icon: 'bed-outline' },
{ value: 'cars', label: 'Cars', icon: 'car-outline' },
]

const MANY = [
{ value: 'overview', label: 'Overview' },
{ value: 'specifications', label: 'Specifications' },
{ value: 'reviews', label: 'Reviews' },
{ value: 'questions', label: 'Questions' },
{ value: 'shipping', label: 'Shipping' },
]

export default function App() {
return (
<SafeAreaProvider>
<ThemeProvider>
<View style={{ flex: 1, gap: 24, paddingTop: 24 }}>
{/* Primary: icon above the label, 64dp tall */}
<Tabs items={TRAVEL} />
{/* Secondary: icon inline, 48dp, full-width indicator */}
<Tabs variant="secondary" items={TRAVEL} />
{/* Scrollable: natural widths, active tab scrolled into view */}
<Tabs scrollable items={MANY} defaultValue="reviews" />
</View>
</ThemeProvider>
</SafeAreaProvider>
)
}
PropValuesDefaultMeaning
variant'primary' | 'secondary''primary'Which anatomy the row uses
scrollablebooleanfalseNatural tab widths with horizontal scroll, instead of an equal split
edgePaddingnumber52Padding before the first and after the last tab. Scrollable rows only
showDividerbooleantrueThe 1dp divider along the bottom of the row

A fixed row divides its width equally between the tabs, so a long label truncates to one line. Reach for scrollable past four tabs, or when the labels are long enough to truncate — scrollable tabs take their natural width, floored at 90dp.

An icon changes the anatomy per variant: a primary tab stacks it above the label and grows to 64dp, a secondary tab puts it before the label and stays 48dp.

Selection

value + onValueChange is the controlled form; leave value off and the row tracks the selection itself, starting on defaultValue or the first item. A tab row with nothing active reads as broken, which is why there is no "no selection" state.

{/* Controlled — the row shows what your state says */}
<Tabs items={items} value={section} onValueChange={setSection} />

{/* Uncontrolled — the row remembers, and tells you */}
<Tabs items={items} defaultValue="trips" onValueChange={load} />

onValueChange fires on every press, controlled or not.

Wiring it to a navigator

Tabs deliberately stops at the bar. With expo-router, drive it from the current route:

import { Tabs } from '@rootnative/components'
import { usePathname, useRouter } from 'expo-router'

const SECTIONS = [
{ value: '/library/albums', label: 'Albums' },
{ value: '/library/artists', label: 'Artists' },
]

function LibraryTabs() {
const pathname = usePathname()
const router = useRouter()

return (
<Tabs
items={SECTIONS}
value={SECTIONS.find((s) => pathname.startsWith(s.value))?.value}
onValueChange={(value) => router.replace(value)}
/>
)
}

The same shape works for react-navigation (useNavigationState + navigation.navigate) or for nothing at all — a useState and a conditional render.

The indicator

The indicator is placed from measured geometry, not from a flex rule, because a primary indicator matches the width of the tab's content — the label, or the icon-and-label column when an icon stacks above it — rather than the width of the tab (floored at 24dp so a one-character label still gets something to see). A secondary indicator spans the whole tab.

It stays unmounted until the active tab reports a layout, so it appears in place instead of sliding in from the row's leading edge on first render. After that, both its offset and its width ride springDefaultSpatial and collapse to a snap under reduced motion — see Motion.

On a scrollable row, selecting a tab that is partly off screen scrolls it into view, centred where there is room to centre it.

Overrides

PropTarget
containerColorThe row background
contentColorLabel and icon color of inactive tabs
selectedContentColorLabel and icon color of the active tab; the state layers re-derive from it
indicatorColorThe active indicator
labelStyleEvery tab's label Text — does not affect the icons
styleThe row container

Disabled tabs always use the MD3 disabled treatment (38% content); no override changes that.

When you pass testID, the indicator and the divider carry <testID>-indicator and <testID>-divider so a test can assert on them.

Tokens

TokenPrimarySecondary
Containersurface, elevation 0, square cornerssurface, elevation 0, square corners
Height48dp, 64dp with an icon above the label48dp
LabeltitleSmall — active primary, inactive onSurfaceVarianttitleSmall — active onSurface, inactive onSurfaceVariant
Icon24dp, same colors as the label24dp, same colors as the label
Indicatorprimary, 3dp, matched to the tab's contentprimary, 2dp, full tab width
Padding16dp horizontal per tab16dp horizontal per tab
Divider1dp outlineVariant along the bottom1dp outlineVariant along the bottom

Two values differ from androidx: the secondary indicator is 2dp per the spec, where compose falls back to the primary token's 3dp because SecondaryNavigationTabTokens declares no indicator; and an icon-and-label tab is 64dp per IconAndLabelTextContainerHeight, where compose's Tab still uses a legacy 72dp.

Accessibility

  • The row reports role="tablist" and each tab role="tab" with accessibilityState.selected. Pass accessibilityLabel to name the row itself ("Sections", "Library").
  • A tab's screen-reader name falls back to its label; set accessibilityLabel on the item for an icon-only tab.
  • Disabled tabs expose accessibilityState.disabled and stop responding.
  • On web, tabs show a keyboard focus ring (secondary, 3dp) driven by the same focus-visible state as the rest of the library.
  • Because Tabs owns no panels, aria-controls is yours to add if you render panels that need it.

Props

PropTypeDefaultRequiredDescription
itemsTabItem[]-YesThe tabs to render.
valuestring-NoValue of the active tab (controlled).
defaultValuestring-NoValue of the initially active tab (uncontrolled). Defaults to the first item — a tab row with nothing active reads as broken.
onValueChange(value: string) => void-NoCalled with the value of the tab that was pressed.
variantenumprimaryNoTab anatomy.
scrollablebooleanNoLay the tabs out at their natural width and scroll horizontally, instead of dividing the row equally between them. Reach for it past four tabs, or when the labels are long enough to truncate.
edgePaddingnumber52NoPadding before the first and after the last tab, in dp. Scrollable rows only — a fixed row divides the full width between its tabs.
showDividerbooleanNoWhether the 1dp divider is drawn along the bottom of the row.
containerColorstringsurfaceNoOverride the row background.
contentColorstringonSurfaceVariantNoOverride the label and icon color of inactive tabs.
selectedContentColorstringprimary (primary variant) / onSurface (secondary variant)NoOverride the label and icon color of the active tab. State-layer colors are derived from it automatically.
indicatorColorstringprimaryNoOverride the active indicator color.
labelStyleStyleProp<TextStyle>-NoStyle applied to every tab's label `Text` — does not affect the icons.
styleStyleProp<ViewStyle>-NoStyle applied to the row container.
accessibilityLabelstring-NoScreen-reader label for the tab row itself.
testIDstring-NoTest id applied to the row container.
onKeyDown(event: { nativeEvent: { key?: string; }; }) => void-No-