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.
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>;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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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 |
| elevationshared | 0 | 1 | 2 | 3 | 0 | Drop shadow depth. 0 and flat: this bar is attached to the edge of the window |
| value | string | number | null | — | The destination the reader is on. Use with onValueChange for a controlled bar |
| defaultValue | string | number | null | null | Which starts current, for an uncontrolled bar |
| onValueChange | (value: string | number) => void | — | Called 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 |
| divider | boolean | true | Draws a hairline along the top edge |
| safeArea | boolean | true | Keeps the row clear of the home indicator. The sheet still reaches the bottom |
| disabled | boolean | false | Every destination stops answering |
| label | string | — | The name the bar is announced by |
| render | RenderProp | — | Renders something other than a <nav> |
| children | ReactNode | — | The PlBottomNavigationItems |
| Prop | Type | Default | Description |
|---|---|---|---|
| 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 |
| onChanged | ValueChanged<T>? | — | Called with the destination that was chosen. Leaving it out freezes the bar |
| variantshared | PlassVariant | PlassVariant.glass | What the sheet is made of. Never dyed — the family is carried by the current item |
| sizeshared | PlassSize | PlassSize.md | The row's floor, the glyph's size, and the type scale of the name under it |
| colorshared | PlassColor | PlassColor.primary | Semantic colour role. Arbitrary colour values are not accepted |
| densityshared | PlassDensity | PlassDensity.standard | Padding only — never the height, never the type scale |
| elevationshared | int | 0 | Drop shadow depth. 0 and flat: this bar is attached to the edge of the window |
| labels | PlBottomNavigationLabels | PlBottomNavigationLabels.all | Which names are drawn. An undrawn name is still in the document |
| divider | bool | true | Draws a hairline along the top edge |
| safeArea | bool | true | Keeps the row clear of the home indicator. The sheet still reaches the bottom |
| disabled | bool | false | Every destination stops answering |
| label | String? | — | The name the bar is announced by |
PlBottomNavigationItem
| Prop | Type | Default | Description |
|---|---|---|---|
| value * | string | number | — | Identifies the destination. What onValueChange reports |
| icon | ReactNode | — | The glyph above the name |
| href | string | — | Renders the item as a real link rather than as a button |
| disabled | boolean | false | Unavailable, but still part of the set |
| children | ReactNode | — | The destination's name. Read out even when labels keeps it undrawn |
| Prop | Type | Default | Description |
|---|---|---|---|
| value * | T | — | Identifies the destination. What onValueChange reports |
| label * | String | — | The 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 |
| icon | Widget? | — | The glyph above the name |
| disabled | bool | false | Unavailable, 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
Links and landmarks
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.
import { PlBottomNavigation, PlBottomNavigationItem, PlTypography } from 'plass-ui';
import { AccountGlyph, HomeGlyph, SearchGlyph } from './glyphs';
export default function BottomNavigationLabels() {
return (
<div className="flex w-full max-w-sm flex-col gap-4">
{(['all', 'selected', 'none'] as const).map((labels) => (
<div key={labels} className="flex flex-col gap-1">
<PlTypography level="caption">labels={labels}</PlTypography>
<PlBottomNavigation
position="static"
labels={labels}
defaultValue="search"
safeArea={false}
>
<PlBottomNavigationItem value="home" icon={<HomeGlyph />}>
Home
</PlBottomNavigationItem>
<PlBottomNavigationItem value="search" icon={<SearchGlyph />}>
Search
</PlBottomNavigationItem>
<PlBottomNavigationItem value="account" icon={<AccountGlyph />}>
Account
</PlBottomNavigationItem>
</PlBottomNavigation>
</div>
))}
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
import 'package:plass_ui_example/demos/bottom_navigation/destinations.dart';
class BottomNavigationLabels extends StatelessWidget {
const BottomNavigationLabels({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 384,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
spacing: 16,
children: <Widget>[
for (final PlBottomNavigationLabels labels in PlBottomNavigationLabels.values)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 4,
children: <Widget>[
PlTypography('labels: ${labels.name}', level: PlTypographyLevel.caption),
PlBottomNavigation<String>(
items: destinations.take(3).toList(),
value: 'search',
labels: labels,
safeArea: false,
onChanged: (String _) {},
),
],
),
],
),
);
}
}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.
import { PlBottomNavigation, PlBottomNavigationItem } from 'plass-ui';
import { HomeGlyph, SavedGlyph, SearchGlyph } from './glyphs';
export default function BottomNavigationVariants() {
return (
<div className="flex w-full max-w-sm flex-col gap-4">
{(['glass', 'solid', 'ghost'] as const).map((variant) => (
<PlBottomNavigation
key={variant}
variant={variant}
position="static"
defaultValue="home"
safeArea={false}
>
<PlBottomNavigationItem value="home" icon={<HomeGlyph />}>
Home
</PlBottomNavigationItem>
<PlBottomNavigationItem value="search" icon={<SearchGlyph />}>
Search
</PlBottomNavigationItem>
<PlBottomNavigationItem value="saved" icon={<SavedGlyph />}>
Saved
</PlBottomNavigationItem>
</PlBottomNavigation>
))}
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
import 'package:plass_ui_example/demos/bottom_navigation/destinations.dart';
class BottomNavigationVariants extends StatelessWidget {
const BottomNavigationVariants({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 384,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
spacing: 16,
children: <Widget>[
for (final PlassVariant variant in PlassVariant.values)
PlBottomNavigation<String>(
items: destinations.take(3).toList(),
value: 'home',
variant: variant,
safeArea: false,
onChanged: (String _) {},
),
],
),
);
}
}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
import { PlBottomNavigation, PlBottomNavigationItem } from 'plass-ui';
import { HomeGlyph, SavedGlyph, SearchGlyph } from './glyphs';
export default function BottomNavigationSizes() {
return (
<div className="flex w-full max-w-sm flex-col gap-4">
{(['sm', 'md', 'lg'] as const).map((size) => (
<PlBottomNavigation
key={size}
size={size}
position="static"
defaultValue="home"
safeArea={false}
>
<PlBottomNavigationItem value="home" icon={<HomeGlyph />}>
Home
</PlBottomNavigationItem>
<PlBottomNavigationItem value="search" icon={<SearchGlyph />}>
Search
</PlBottomNavigationItem>
<PlBottomNavigationItem value="saved" icon={<SavedGlyph />}>
Saved
</PlBottomNavigationItem>
</PlBottomNavigation>
))}
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
import 'package:plass_ui_example/demos/bottom_navigation/destinations.dart';
class BottomNavigationSizes extends StatelessWidget {
const BottomNavigationSizes({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 384,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
spacing: 16,
children: <Widget>[
for (final PlassSize size in <PlassSize>[PlassSize.sm, PlassSize.md, PlassSize.lg])
PlBottomNavigation<String>(
items: destinations.take(3).toList(),
value: 'home',
size: size,
safeArea: false,
onChanged: (String _) {},
),
],
),
);
}
}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.
import { PlBottomNavigation, PlBottomNavigationItem } from 'plass-ui';
import { AccountGlyph, HomeGlyph, SearchGlyph } from './glyphs';
export default function BottomNavigationLinks() {
return (
<div className="w-full max-w-sm">
<PlBottomNavigation position="static" defaultValue="home" safeArea={false}>
<PlBottomNavigationItem value="home" href="#bottom-navigation" icon={<HomeGlyph />}>
Home
</PlBottomNavigationItem>
<PlBottomNavigationItem value="search" href="#bottom-navigation" icon={<SearchGlyph />}>
Search
</PlBottomNavigationItem>
<PlBottomNavigationItem value="account" disabled icon={<AccountGlyph />}>
Account
</PlBottomNavigationItem>
</PlBottomNavigation>
</div>
);
}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
labelsis 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". Neveraria-pressed, which would make it a toggle.
- The current destination is marked selected, which is Flutter's nearest word for
aria-currentand 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
| React | Flutter | Why |
|---|---|---|
<PlBottomNavigationItem> children | items: 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 item | label, a String | It 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 / onValueChange | value / onChanged | Flutter's own controls are controlled, and its name for the callback. |
position | — | A Flutter screen has no page scroll to opt out of. The app's scaffold decides where the bar goes. |
href | — | There is no link element and nothing crawls a Flutter app. onChanged is where a router is called. |
aria-current="page" | the selected flag | Flutter's semantics tree has no current. |
className, style | — | There is no class list and no style attribute to pass through. |