Skip to content

PlBottomNavigation

A row of destinations held against the bottom edge of the window. A <nav> of real links or buttons, never a tab list, because it switches what the page is rather than which panel of one is showing.

React
tsx
import { PlBottomNavigation, PlBottomNavigationItem } from 'plass-ui';

<PlBottomNavigation value={where} onValueChange={setWhere} label="Main">
  <PlBottomNavigationItem value="home" icon={<HomeIcon />} href="/">
    Home
  </PlBottomNavigationItem>
  <PlBottomNavigationItem value="search" icon={<SearchIcon />} href="/search">
    Search
  </PlBottomNavigationItem>
</PlBottomNavigation>;
dart
import 'package:plass_ui/plass_ui.dart';

PlBottomNavigation<String>(
  value: where,
  onChanged: (String next) => setState(() => where = next),
  label: 'Main',
  items: const <PlBottomNavigationItem<String>>[
    PlBottomNavigationItem<String>(value: 'home', label: 'Home', icon: HomeIcon()),
    PlBottomNavigationItem<String>(value: 'search', label: 'Search', icon: SearchIcon()),
  ],
);

Props

PropTypeDefaultDescription
variantshared'solid' | 'glass' | 'ghost''glass'What the sheet is made of. Never dyed — the family is carried by the current item
sizeshared'xs' | 'sm' | 'md' | 'lg' | 'xl''md'The row's floor, the glyph's size, and the type scale of the name under it
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 and flat: this bar is attached to the edge of the window
valuestring | number | nullThe destination the reader is on. Use with onValueChange for a controlled bar
defaultValuestring | number | nullnullWhich starts current, for an uncontrolled bar
onValueChange(value: string | number) => voidCalled with the destination that was chosen
position'static' | 'sticky' | 'fixed''fixed'How the bar sits in the page's scroll
labels'all' | 'selected' | 'none''all'Which names are drawn. An undrawn name is still in the document
dividerbooleantrueDraws a hairline along the top edge
safeAreabooleantrueKeeps the row clear of the home indicator. The sheet still reaches the bottom
disabledbooleanfalseEvery destination stops answering
labelstringThe name the bar is announced by
renderRenderPropRenders something other than a <nav>
childrenReactNodeThe PlBottomNavigationItems
PropTypeDefaultDescription
items * List<PlBottomNavigationItem<T>>The destinations, as a list of descriptions rather than children — the bar has to know which is current and how many there are
value * T?The destination the reader is on. Use with onValueChange for a controlled bar
onChangedValueChanged<T>?Called with the destination that was chosen. Leaving it out freezes the bar
variantsharedPlassVariantPlassVariant.glassWhat the sheet is made of. Never dyed — the family is carried by the current item
sizesharedPlassSizePlassSize.mdThe row's floor, the glyph's size, and the type scale of the name under it
colorsharedPlassColorPlassColor.primarySemantic colour role. Arbitrary colour values are not accepted
densitysharedPlassDensityPlassDensity.standardPadding only — never the height, never the type scale
elevationsharedint0Drop shadow depth. 0 and flat: this bar is attached to the edge of the window
labelsPlBottomNavigationLabelsPlBottomNavigationLabels.allWhich names are drawn. An undrawn name is still in the document
dividerbooltrueDraws a hairline along the top edge
safeAreabooltrueKeeps the row clear of the home indicator. The sheet still reaches the bottom
disabledboolfalseEvery destination stops answering
labelString?The name the bar is announced by

PlBottomNavigationItem

PropTypeDefaultDescription
value * string | numberIdentifies the destination. What onValueChange reports
iconReactNodeThe glyph above the name
hrefstringRenders the item as a real link rather than as a button
disabledbooleanfalseUnavailable, but still part of the set
childrenReactNodeThe destination's name. Read out even when labels keeps it undrawn
PropTypeDefaultDescription
value * TIdentifies the destination. What onValueChange reports
label * StringThe destination's name. A String rather than a widget, and required — it is both the name that is drawn and the one that is always announced
iconWidget?The glyph above the name
disabledboolfalseUnavailable, but still part of the set

Every native <nav> attribute passes through on the bar and every native <button> attribute on an item. color is excluded because it is a Plass prop here, and onChange because the bar spells it onValueChange.

The bar is generic in the destination's type, so value and onChanged are checked rather than dynamic, and it is controlled (handed a value, reporting the one that should replace it), which is how every other input in this package works.

An item is a description rather than a widget, the idiom PlAccordion and PlTable already use: the bar has to know which destination is current and how many there are, and a Widget is opaque.

An item takes no size, color or variant of its own. All three belong to the set, which is the only place they can be set once and mean the same thing for every destination, the same arrangement PlTabs and PlSegmentedButton use. What the shared axes mean across the library is in prop conventions.

Examples

A tab list owes a keyboard reader one tab stop for the whole set and arrow keys within it, and it owes a screen reader a panel per tab. A bottom navigation does neither of those things: it switches what the page is. Claiming the role without the behaviour is worse for a keyboard reader than never claiming it at all.

What is claimed instead is aria-current, which is the honest statement. This is the destination you are on. Never aria-pressed, which would make it a toggle.

Placement

position defaults to fixed, against the static a layout component would take, because that is what a bottom navigation is: held against the bottom edge of the window whatever the page does. sticky is the same thing inside a scrolling panel, and static puts it in the flow, which is what the previews on this page use, since a fixed bar would leave the page and stick to the browser window.

A bar spanning an edge of the window has nothing behind its corners, so only one sitting in the flow is a sheet with corners at all.

There is no position, because a Flutter screen has no page scroll for a widget to opt out of. A bar goes in whatever the app's scaffold calls its bottom slot, or at the bottom of a Stack, and either way it is the app that decides, not the bar.

Its corners are square for the reason the React build's are: a bar spanning an edge of the screen has nothing behind them to cut.

labels

all names every destination, and it is the only setting that works for a reader who has not used the app before. selected names only the current one. none draws no names at all.

The bar keeps its height at every setting, because the named item is always the tallest one. What changes is how much of the row is words.

Undrawn is not unsaid. A glyph on its own has no accessible name, so a name that is not drawn is kept in the document in a clipped box rather than dropped with the pixels.

React

variant and color

The sheet is never dyed, exactly as on a PlCard. A bar holds destinations that arrive with their own icons, and tinting the pane under them puts every one on a background it was not chosen against. What carries the colour family is the one item that is current.

React

divider, safeArea and elevation

divider draws a hairline along the top edge and is on by default: a bar pinned over a scrolling page has content passing underneath it at every moment, and a translucent sheet with nothing marking its edge reads as part of that.

safeArea keeps the row clear of the home indicator on a phone. The sheet still reaches the bottom of the screen, only the items move up, because a bar that stopped above the indicator would leave a stripe of page showing under the glass.

elevation is 0, and flat is right: the bar is attached to the edge of the window rather than floating over the middle of it, and divider is what separates it from the content. The bar that floats over the page is a different object, and it is PlFloatingBottomNavigation.

size

React

href

With an href an item is a real <a>, which is what makes a long press offer "open in a new tab" and what puts the destination in the status bar, neither of which a <button> that calls router.push can do. Without one it is a <button>, because a <div> carrying a click handler is invisible to a keyboard.

A disabled link loses its href rather than keeping a live one behind an aria-disabled, because disabled is not a state an <a> can be in.

React

Accessibility

  • A named group a screen reader can skip to and skip past, a <nav> landmarka semantics container.
  • Every item has an accessible name whatever labels is set to. Undrawn is not unsaid.
  • Items are in document order, each its own focus stop, which is what a set of destinations should be, and what a roving tab index would take away.
  • The current destination carries aria-current="page". Never aria-pressed, which would make it a toggle.
  • The current destination is marked selected, which is Flutter's nearest word for aria-current and the one that does not claim the item is a toggle.
  • Each item is a button node with the name and the tap action on it; the drawing inside is excluded, so a glyph never becomes a second thing to read.

Differences from the React build

ReactFlutterWhy
<PlBottomNavigationItem> childrenitems: List<PlBottomNavigationItem<T>>The bar has to reason about its members, and a Widget is opaque. The idiom PlAccordion and PlTable already use.
children on an itemlabel, a StringIt is the name that is drawn and the name that is announced. A widget could be the first; only a string can be both.
value / defaultValue / onValueChangevalue / onChangedFlutter's own controls are controlled, and its name for the callback.
positionA Flutter screen has no page scroll to opt out of. The app's scaffold decides where the bar goes.
hrefThere is no link element and nothing crawls a Flutter app. onChanged is where a router is called.
aria-current="page"the selected flagFlutter's semantics tree has no current.
className, styleThere is no class list and no style attribute to pass through.

Released under the MIT License