Skip to main content

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 is a bar, not a navigator

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:

ValueMeaning
'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

PropTarget
containerColorThe bar background
contentColorIcon and label color of inactive destinations
selectedContentColorIcon and label color of the active destination; the state layers re-derive from it
indicatorColorThe active indicator pill
labelStyleEvery destination's label Text — does not affect the icons
styleThe 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

TokenValue
ContainersurfaceContainer, elevation level 2
Height80dp (+ bottom inset with insetBottom)
Active indicator56×32dp pill, secondaryContainer, full corners
Icon24dp — active onSecondaryContainer, inactive onSurfaceVariant
LabellabelMedium — active secondary, inactive onSurfaceVariant
Indicator → label gap4dp
Item padding8dp 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 destination role="tab" with accessibilityState.selected. Pass accessibilityLabel to name the bar itself ("Main navigation").
  • A destination's screen-reader name falls back to its label — including when labelVisibility hides the visible label; set accessibilityLabel on the item to override it.
  • Disabled destinations expose accessibilityState.disabled and 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

PropTypeDefaultRequiredDescription
itemsNavigationBarItem[]-YesThe destinations to render. MD3 recommends 3–5.
valuestring-NoValue of the active destination (controlled).
defaultValuestring-NoValue of the initially active destination (uncontrolled). Defaults to the first item — a navigation bar with nothing active reads as broken.
onValueChange(value: string) => void-NoCalled with the value of the destination that was pressed.
labelVisibilityenumalwaysNoWhen destination labels are shown.
insetBottombooleanNoWhen `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.
containerColorstringsurfaceContainerNoOverride the bar background.
contentColorstringonSurfaceVariantNoOverride the icon and label color of inactive destinations.
selectedContentColorstringonSecondaryContainer (icon) / secondary (label)NoOverride the icon and label color of the active destination. State-layer colors are derived from it automatically.
indicatorColorstringsecondaryContainerNoOverride the active indicator pill color.
labelStyleStyleProp<TextStyle>-NoStyle applied to every destination's label `Text` — does not affect the icons.
styleStyleProp<ViewStyle>-NoStyle applied to the bar container.
accessibilityLabelstring-NoScreen-reader label for the bar itself.
testIDstring-NoTest 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-