PlFloatingBottomNavigation
A row of round destinations floating clear of the bottom edge of the window. A capsule of clear glass with a key of tinted glass riding in it, the design language's own sentence, and nothing added to it.
import { PlFloatingBottomNavigation, PlFloatingBottomNavigationItem } from 'plass-ui';
<PlFloatingBottomNavigation value={where} onValueChange={setWhere} label="Main">
<PlFloatingBottomNavigationItem value="home" icon={<HomeIcon />}>
Home
</PlFloatingBottomNavigationItem>
<PlFloatingBottomNavigationItem value="search" icon={<SearchIcon />}>
Search
</PlFloatingBottomNavigationItem>
</PlFloatingBottomNavigation>;import 'package:plass_ui/plass_ui.dart';
PlFloatingBottomNavigation<String>(
value: where,
onChanged: (String next) => setState(() => where = next),
label: 'Main',
items: const <PlFloatingBottomNavigationItem<String>>[
PlFloatingBottomNavigationItem<String>(value: 'home', label: 'Home', icon: HomeIcon()),
PlFloatingBottomNavigationItem<String>(value: 'search', label: 'Search', icon: SearchIcon()),
],
);Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variantshared | 'solid' | 'glass' | 'ghost' | 'glass' | What the capsule is made of. ghost has no capsule at all — the discs float on their own |
| sizeshared | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | The disc's diameter and the gap under the bar, on the control ladder |
| colorshared | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | Semantic colour role. Arbitrary colour values are not accepted |
| densityshared | 'default' | 'compact' | 'default' | Changes the air inside the capsule and the gap between discs |
| elevationshared | 0 | 1 | 2 | 3 | 2 | Drop shadow depth. 2, because this bar is not part of the page — it hovers over it |
| 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 |
| safeArea | boolean | true | Adds the home indicator to the gap under the bar |
| 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 PlFloatingBottomNavigationItems |
| Prop | Type | Default | Description |
|---|---|---|---|
| items * | List<PlFloatingBottomNavigationItem<T>> | — | The destinations, as a list of descriptions rather than children |
| 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 |
| variantshared | PlassVariant | PlassVariant.glass | What the capsule is made of. ghost has no capsule at all — the discs float on their own |
| sizeshared | PlassSize | PlassSize.md | The disc's diameter and the gap under the bar, on the control ladder |
| colorshared | PlassColor | PlassColor.primary | Semantic colour role. Arbitrary colour values are not accepted |
| densityshared | PlassDensity | PlassDensity.standard | Changes the air inside the capsule and the gap between discs |
| elevationshared | int | 2 | Drop shadow depth. 2, because this bar is not part of the page — it hovers over it |
| safeArea | bool | true | Adds the home indicator to the gap under the bar |
| disabled | bool | false | Every destination stops answering |
| label | String? | — | The name the bar is announced by |
PlFloatingBottomNavigationItem
| Prop | Type | Default | Description |
|---|---|---|---|
| value * | string | number | — | Identifies the destination. What onValueChange reports |
| icon | ReactNode | — | The glyph. It is the whole of what a reader sees |
| 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. Never drawn, always read |
| Prop | Type | Default | Description |
|---|---|---|---|
| value * | T | — | Identifies the destination. What onValueChange reports |
| label * | String | — | The destination's name. Required and never drawn — only ever read |
| icon | Widget? | — | The glyph. It is the whole of what a reader sees |
| 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.
The bar is generic in the destination's type and controlled, and its destinations are descriptions rather than widgets, the same three decisions PlBottomNavigation makes, for the same reasons.
What the shared axes mean across the library is in prop conventions.
Compared with PlBottomNavigation
It is the other half of PlBottomNavigation, and a different object rather than a variant of one.
That bar is attached to the edge of the window: full width, a hairline against the content it is over, its sheet running under the home indicator, and flat, because a thing lying against an edge does not cast a shadow onto it. This one is not part of the page at all. Everything that follows comes from that single difference. The capsule, the gap under it, the shadow it defaults to, and the pill corners it is allowed.
A floating boolean would have been the smaller API and the worse one: half the props on each bar would have meant nothing on the other, and the first divider={true} floating would have been a bar with a hairline along the top of a capsule that has no content behind it.
Examples
The discs
Every destination is a disc with a glyph in it and no name drawn, which is what keeps a row of five inside the width of a phone.
rounded-full is one of the very few places the library allows a pill, and it is allowed for the reason a PlSegmentedButton's groove is: this is an object floating clear of the page rather than a sheet lying on it. The house fillet is about a sheet with its corners cut, and a sheet that is not on anything has no corners to cut.
The current destination is a key of tinted glass riding in the clear sheet. Every other one has no surface until the pointer is on it.
The moving key
The key is one element, measured off whichever disc is current and animated between them the way a PlSegmentedButton's tile is. It is not a fill that appears on one disc while it disappears from another: two discs cross-fading is two objects, and a bar with a key in it has one, where that key goes is the whole of what this component has to say.
Nothing is transformed. The key is an empty box moved by its own left, top, width and height, so no glyph in the row is resampled while it travels, and the house rule against moving a control survives a component whose entire point is that something moves.
The first placement is instant, however it arrives. A key that has only just mounted has nowhere to travel from, so the destination a bar opens on appears under its own disc rather than flying in from the left edge of the capsule.
Names
children is required in practice and never drawn. A disc with a glyph in it has no accessible name at all, and a row of glyphs with no names is exactly the defect PlIconButton's label exists to make impossible. It would be just as easy to ship here.
If a sighted reader also needs the words, put a PlTooltip around the item. What this component will not do is draw a name on some discs and not on others: a row where one item is a capsule and four are circles is a row with a layout shift in it every time the destination changes.
variant
glass is the default and the whole point: a clear sheet over a blurred backdrop with a hairline around it. solid is the same sheet at its most opaque, for a bar that sits over photography. ghost has no capsule at all, the discs float on their own.
import { PlFloatingBottomNavigation, PlFloatingBottomNavigationItem, PlTypography } from 'plass-ui';
import { HomeGlyph, SavedGlyph, SearchGlyph } from '../bottom-navigation/glyphs';
export default function FloatingBottomNavigationVariants() {
return (
<div className="flex w-full max-w-sm flex-col gap-3">
{(['glass', 'solid', 'ghost'] as const).map((variant) => (
<div key={variant} className="flex flex-col gap-1">
<PlTypography level="caption">{variant}</PlTypography>
<PlFloatingBottomNavigation
variant={variant}
position="static"
safeArea={false}
defaultValue="home"
>
<PlFloatingBottomNavigationItem value="home" icon={<HomeGlyph />}>
Home
</PlFloatingBottomNavigationItem>
<PlFloatingBottomNavigationItem value="search" icon={<SearchGlyph />}>
Search
</PlFloatingBottomNavigationItem>
<PlFloatingBottomNavigationItem value="saved" icon={<SavedGlyph />}>
Saved
</PlFloatingBottomNavigationItem>
</PlFloatingBottomNavigation>
</div>
))}
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
import 'package:plass_ui_example/demos/floating_bottom_navigation/destinations.dart';
class FloatingBottomNavigationVariants extends StatelessWidget {
const FloatingBottomNavigationVariants({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 384,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: <Widget>[
for (final PlassVariant variant in PlassVariant.values)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 4,
children: <Widget>[
PlTypography(variant.name, level: PlTypographyLevel.caption),
PlFloatingBottomNavigation<String>(
items: destinations.take(3).toList(),
value: 'home',
variant: variant,
safeArea: false,
onChanged: (String _) {},
),
],
),
],
),
);
}
}color
The capsule is never dyed, exactly as on a PlCard. What carries the family is the one disc that is current.
import { PlFloatingBottomNavigation, PlFloatingBottomNavigationItem } from 'plass-ui';
import { HomeGlyph, SavedGlyph, SearchGlyph } from '../bottom-navigation/glyphs';
export default function FloatingBottomNavigationColors() {
return (
<div className="flex w-full max-w-sm flex-col gap-2">
{(['primary', 'success', 'danger'] as const).map((color) => (
<PlFloatingBottomNavigation
key={color}
color={color}
position="static"
safeArea={false}
defaultValue="search"
>
<PlFloatingBottomNavigationItem value="home" icon={<HomeGlyph />}>
Home
</PlFloatingBottomNavigationItem>
<PlFloatingBottomNavigationItem value="search" icon={<SearchGlyph />}>
Search
</PlFloatingBottomNavigationItem>
<PlFloatingBottomNavigationItem value="saved" icon={<SavedGlyph />}>
Saved
</PlFloatingBottomNavigationItem>
</PlFloatingBottomNavigation>
))}
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
import 'package:plass_ui_example/demos/floating_bottom_navigation/destinations.dart';
class FloatingBottomNavigationColors extends StatelessWidget {
const FloatingBottomNavigationColors({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 384,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
for (final PlassColor color in <PlassColor>[
PlassColor.primary,
PlassColor.success,
PlassColor.danger,
])
PlFloatingBottomNavigation<String>(
items: destinations.take(3).toList(),
value: 'search',
color: color,
safeArea: false,
onChanged: (String _) {},
),
],
),
);
}
}size, elevation and the gap
size is the disc's diameter, on the control ladder, so a floating bar at md is a row of 40px discs and lands on the same numbers as everything else.
elevation is 2, against the 0 almost everything else defaults to, and that is not an inconsistency. Every other sheet in the library rests on the page and earns its separation from the glass edge, so a shadow is opt-in. This one hovers over whatever is underneath it, and a capsule lying flat on the content it is floating over reads as a mistake.
The gap under the bar comes off the same size ladder, with env(safe-area-inset-bottom) added to it while safeArea is on.
import { PlFloatingBottomNavigation, PlFloatingBottomNavigationItem } from 'plass-ui';
import { HomeGlyph, SavedGlyph, SearchGlyph } from '../bottom-navigation/glyphs';
export default function FloatingBottomNavigationSizes() {
return (
<div className="flex w-full max-w-sm flex-col gap-2">
{(['sm', 'md', 'lg'] as const).map((size) => (
<PlFloatingBottomNavigation
key={size}
size={size}
position="static"
safeArea={false}
defaultValue="home"
>
<PlFloatingBottomNavigationItem value="home" icon={<HomeGlyph />}>
Home
</PlFloatingBottomNavigationItem>
<PlFloatingBottomNavigationItem value="search" icon={<SearchGlyph />}>
Search
</PlFloatingBottomNavigationItem>
<PlFloatingBottomNavigationItem value="saved" icon={<SavedGlyph />}>
Saved
</PlFloatingBottomNavigationItem>
</PlFloatingBottomNavigation>
))}
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
import 'package:plass_ui_example/demos/floating_bottom_navigation/destinations.dart';
class FloatingBottomNavigationSizes extends StatelessWidget {
const FloatingBottomNavigationSizes({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 384,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
for (final PlassSize size in <PlassSize>[PlassSize.sm, PlassSize.md, PlassSize.lg])
PlFloatingBottomNavigation<String>(
items: destinations.take(3).toList(),
value: 'home',
size: size,
safeArea: false,
onChanged: (String _) {},
),
],
),
);
}
}Accessibility
- A named
<nav>landmark, and every disc a real link or button in document order, one tab stop each. - The current destination carries
aria-current="page". Neveraria-pressed. - Every disc has an accessible name, and none of them is drawn. The name lives in a 1px clipped box: invisible to a sighted reader, present to every other kind.
- The strip the capsule is centred in spans the window and takes no pointer events; only the capsule takes them back. A transparent band across the bottom of a page that swallowed presses would be a band nobody could scroll through.
- The focus ring on a disc is offset rather than flush, which is the exception the rest of the library does not make: a flush ring on a circle is the circle's own edge thickening, which reads as a border rather than as focus.
Differences from the React build
| React | Flutter | Why |
|---|---|---|
<PlFloatingBottomNavigationItem> children | items: List<…<T>> | The bar has to reason about its members. The idiom the rest of the package uses. |
children on an item | label, a String | It is only ever a semantics label here, and a semantics label is a string. |
value / defaultValue / onValueChange | value / onChanged | Flutter's own controls are controlled. |
position | — | A Flutter screen has no page scroll to opt out of; the app places the bar. |
| a full-width strip with no pointer events | — | There is no strip to build. A fixed element has to span something; a Flutter widget goes exactly where it is put, so the bar is only as wide as its capsule. |
href | — | There is no link element and nothing crawls a Flutter app. |
className, style | — | There is no class list and no style attribute to pass through. |