Skip to content

BottomNavigation

A bar of an app's main destinations, held against the bottom edge of the window. One glyph with a name under it is one destination, and the one the reader is on carries aria-current.

tsx
import { BottomNavigation, BottomNavigationItem } from 'neba';

<BottomNavigation label="Main" value={section} onValueChange={setSection}>
  <BottomNavigationItem value="home" icon={<HomeIcon />}>
    Home
  </BottomNavigationItem>
  <BottomNavigationItem value="search" icon={<SearchIcon />}>
    Search
  </BottomNavigationItem>
</BottomNavigation>;

Props

PropTypeDefaultDescription
variantshared'solid' | 'outline' | 'text''outline'Weight of the bar, said the way a container says it: the sheet is never dyed. What carries the colour family is the one item that is current
sizeshared'xs' | 'sm' | 'md' | 'lg' | 'xl''md'The row’s floor and the scale of the glyph and the name. md is 56px
colorshared'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info''primary'Semantic colour role. Arbitrary colour values are not accepted
densityshared'default' | 'compact''default'Padding only: never the height, never the type scale
elevationshared0 | 1 | 2 | 30Drop shadow depth. 0 by default: the bar is attached to the edge of the window rather than floating over it, and divider is what separates it from the content
valuestring | number | nullThe destination the reader is on. Use with onValueChange for a controlled bar
defaultValuestring | number | nullWhich starts current, for an uncontrolled bar
onValueChange(value: string | number) => voidCalled with the destination that was pressed
positionshared'static' | 'sticky' | 'fixed''fixed'How the bar sits in the page’s scroll. fixed by default, against the static everything else defaults to: a bottom navigation is held against the bottom edge of the window
labels'all' | 'selected' | 'none''all'Which names are drawn. An undrawn name is still in the document for a screen reader
dividerbooleantrueDraws a hairline along the top edge, against the content. On by default, the other way round from Toolbar
safeAreabooleantrueKeeps the bar clear of the home indicator by adding env(safe-area-inset-bottom) under it. The sheet still reaches the bottom of the screen
disabledbooleanfalseEvery destination stops answering
labelstringThe name the bar is announced by: "Main", "Sections"
renderuseRender.RenderPropRenders something other than a nav (render={<footer />}). Base UI’s own escape hatch, and rarely what you want here: a row of destinations is navigation
childrenReactNodeThe BottomNavigationItems
PropTypeDefaultDescription
value * string | numberIdentifies the destination. What onValueChange reports
iconReactNodeThe glyph above the name
hrefstringRenders the item as a link rather than as a button
targetstringWhere an href opens. Anything but this tab gets noopener noreferrer merged into rel
relstringThe link’s rel. Merged with the two tokens above rather than replaced
disabledbooleanfalseUnavailable, but still part of the set
childrenReactNodeThe destination’s name. Read out even when labels keeps it undrawn

Every other <nav> attribute passes through to the root and every other <button> attribute to each destination, except onChange: the change worth listening for is onValueChange.

The shared axes (variant size color density elevation position) are defined in prop conventions.

Examples

position

The default is fixed, against the static everything else in the library defaults to, and that is what this component is: it is held against the bottom edge of the window whatever the page does. The page then needs bottom padding of its own, or its last line sits behind the bar.

sticky keeps the bar in the flow but stops it at the bottom edge of the scrolling region. static is an ordinary sheet in the flow.

labels

all draws every name. selected draws only the current one, and none draws none of them.

An undrawn name is still in the document. A button whose whole label is a glyph has no accessible name at all, so what goes is the pixels and nothing else.

href

A destination with an href is a real <a>. That is what makes a long press offer "open in a new tab" and what puts the address in the status bar, neither of which a <button> calling a router can do.

variant, divider, safeArea

variant says what it says on every other container: the sheet is never dyed, and what carries the colour family is the one destination that is current. divider is the hairline along the top edge, facing the content, and it is on by default. safeArea adds env(safe-area-inset-bottom) under the row to clear a phone's home indicator, while the sheet itself still reaches the bottom of the screen.

Controlling it

Pass value and the bar keeps no state of its own, which is the shape to use when the router already knows where the reader is.

tsx
<BottomNavigation value={pathname} onValueChange={navigate}>
  <BottomNavigationItem value="/home" icon={<HomeIcon />}>
    Home
  </BottomNavigationItem>
</BottomNavigation>

Accessibility

  • The root is a <nav> and label names it. It is not a role="tablist": a tab list promises one tab stop for the set and arrow keys within it, and a bottom navigation changes the page rather than which panel of one is showing.
  • The current destination carries aria-current="page".
  • Each destination is a real <button>, or a real <a> when it is given an href.
  • A name that labels keeps undrawn stays in the document, where it is the destination's accessible name.
  • With position="fixed", pad the bottom of the page by the bar's height, or its last line is covered.

Released under the MIT License