NavigationBar
Navigation bars let people switch between an app's top-level destinations on compact screens. Follows the Material Design 3 Navigation bar specification.
Each destination is an icon over a label, with the active one marked by a secondaryContainer
pill behind its icon. The bar is 80dp tall on a surfaceContainer background.
NavigationBar renders the destinations and tells you which one was pressed. It does not own
screens, mount panels, or integrate with a navigator. Point it at whatever you already use — see
Wiring it to a navigator.
Usage
Pass the destinations as items and read the selection from onValueChange. With no value
prop, the bar tracks the active destination itself and starts on the first item.
import { NavigationBar } 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 DESTINATIONS = [
{ value: 'home', label: 'Home', icon: 'home-outline', selectedIcon: 'home' },
{ value: 'search', label: 'Search', icon: 'magnify' },
{ value: 'library', label: 'Library', icon: 'bookshelf' },
]
const PANELS = {
home: 'Fresh picks for you.',
search: 'Type to find anything.',
library: 'Everything you saved.',
}
function Screen() {
const [destination, setDestination] = useState('home')
return (
<View style={{ flex: 1 }}>
<View style={{ flex: 1, padding: 24 }}>
<Text>{PANELS[destination]}</Text>
</View>
<NavigationBar
items={DESTINATIONS}
value={destination}
onValueChange={setDestination}
insetBottom
/>
</View>
)
}
export default function App() {
return (
<SafeAreaProvider>
<ThemeProvider>
<Screen />
</ThemeProvider>
</SafeAreaProvider>
)
}
MD3 recommends 3–5 destinations. The row is split equally between them, whatever the count.
An item's selectedIcon is shown while it is active — MD3 pairs an outlined resting icon with
its filled counterpart. Leave it off to keep the same icon in both states.
Selection
value + onValueChange is the controlled form; leave value off and the bar tracks the
selection itself, starting on defaultValue or the first item. A navigation bar with nothing
active reads as broken, which is why there is no "no selection" state. onValueChange fires on
every press, controlled or not.
Wiring it to a navigator
NavigationBar deliberately stops at the bar. With expo-router, drive it from the current route:
import { NavigationBar } from '@rootnative/components'
import { usePathname, useRouter } from 'expo-router'
const DESTINATIONS = [
{ value: '/home', label: 'Home', icon: 'home-outline', selectedIcon: 'home' },
{ value: '/search', label: 'Search', icon: 'magnify' },
]
function AppShellBar() {
const pathname = usePathname()
const router = useRouter()
return (
<NavigationBar
items={DESTINATIONS}
value={DESTINATIONS.find((d) => pathname.startsWith(d.value))?.value}
onValueChange={(value) => router.replace(value)}
insetBottom
/>
)
}
The same shape works for react-navigation — render it as a custom tabBar and map
state.index to value.
Label visibility
labelVisibility controls when destination labels are shown:
| Value | Meaning |
|---|---|
'always' (default) | Every destination keeps its label |
'selected' | Only the active destination's label is visible — it fades in on selection while the inactive icons sit centred |
'never' | No labels; every icon is centred |
The label stays required on the item either way: it is also the destination's screen-reader name.
The indicator
The active indicator is a 56×32dp secondaryContainer pill behind the icon. On selection it
fades in while expanding from its centre, riding springDefaultSpatial, and collapses to a hard
cut under reduced motion — see Motion. Hover, press, and focus state layers paint
inside the same pill shape, on active and inactive destinations alike.
Safe area
A bottom-anchored bar must clear the home indicator. Pass insetBottom to wrap the row in a
SafeAreaView that adds the bottom inset below the bar's 80dp — leave it off (the default) when
an ancestor already applies the bottom edge, or the inset would be added twice.
Overrides
| Prop | Target |
|---|---|
containerColor | The bar background |
contentColor | Icon and label color of inactive destinations |
selectedContentColor | Icon and label color of the active destination; the state layers re-derive from it |
indicatorColor | The active indicator pill |
labelStyle | Every destination's label Text — does not affect the icons |
style | The bar container |
Disabled destinations always use the MD3 disabled treatment (38% content); no override changes that.
When you pass testID, each destination carries <testID>-item-<value> and its indicator
<testID>-item-<value>-indicator so a test can assert on them.
Tokens
| Token | Value |
|---|---|
| Container | surfaceContainer, elevation level 2 |
| Height | 80dp (+ bottom inset with insetBottom) |
| Active indicator | 56×32dp pill, secondaryContainer, full corners |
| Icon | 24dp — active onSecondaryContainer, inactive onSurfaceVariant |
| Label | labelMedium — active secondary, inactive onSurfaceVariant |
| Indicator → label gap | 4dp |
| Item padding | 8dp horizontal, destinations split the row equally |
These are the Expressive-updated androidx tokens: the indicator is 56dp wide (not the
pre-Expressive 64dp) and the active label is secondary (not onSurface). The 64dp-tall
Expressive short bar is a possible 1.x variant; this bar uses compose's TallContainerHeight.
Accessibility
- The bar reports
role="tablist"and each destinationrole="tab"withaccessibilityState.selected. PassaccessibilityLabelto name the bar itself ("Main navigation"). - A destination's screen-reader name falls back to its label — including when
labelVisibilityhides the visible label; setaccessibilityLabelon the item to override it. - Disabled destinations expose
accessibilityState.disabledand stop responding. - On web, destinations show a keyboard focus ring (
secondary, 3dp) around the indicator pill, driven by the same focus-visible state as the rest of the library.
Props
| Prop | Type | Default | Required | Description |
|---|---|---|---|---|
items | NavigationBarItem[] | - | Yes | The destinations to render. MD3 recommends 3–5. |
value | string | - | No | Value of the active destination (controlled). |
defaultValue | string | - | No | Value of the initially active destination (uncontrolled). Defaults to the first item — a navigation bar with nothing active reads as broken. |
onValueChange | (value: string) => void | - | No | Called with the value of the destination that was pressed. |
labelVisibility | enum | always | No | When destination labels are shown. |
insetBottom | boolean | | No | When `true`, wraps the row in a SafeAreaView that adds the bottom inset below the bar's 80dp, so it clears the home indicator. Leave it off when an ancestor (e.g. `Layout` with a `bottom` edge) already handles it. |
containerColor | string | surfaceContainer | No | Override the bar background. |
contentColor | string | onSurfaceVariant | No | Override the icon and label color of inactive destinations. |
selectedContentColor | string | onSecondaryContainer (icon) / secondary (label) | No | Override the icon and label color of the active destination. State-layer colors are derived from it automatically. |
indicatorColor | string | secondaryContainer | No | Override the active indicator pill color. |
labelStyle | StyleProp<TextStyle> | - | No | Style applied to every destination's label `Text` — does not affect the icons. |
style | StyleProp<ViewStyle> | - | No | Style applied to the bar container. |
accessibilityLabel | string | - | No | Screen-reader label for the bar itself. |
testID | string | - | No | Test id applied to the bar container. Each destination carries `<testID>-item-<value>` and its indicator `<testID>-item-<value>-indicator`. |
onKeyDown | (event: { nativeEvent: { key?: string; }; }) => void | - | No | - |