BottomNavBar¶
A controlled glass bottom-navigation bar with one to five responsive destinations.
var selectedIndex = 0;
BottomNavBar(
items: const [
BottomNavBarItem(
icon: TsaiIcon(LucideIcons.house, size: 20),
label: 'Home',
),
BottomNavBarItem(
icon: TsaiIcon(LucideIcons.chart_no_axes_column, size: 20),
label: 'Stats',
),
BottomNavBarItem(
icon: TsaiIcon(LucideIcons.credit_card, size: 20),
label: 'Cards',
),
BottomNavBarItem(
icon: TsaiIcon(LucideIcons.user, size: 20),
label: 'Profile',
),
BottomNavBarItem(
icon: TsaiIcon(LucideIcons.settings, size: 20),
label: 'Settings',
),
],
selectedIndex: selectedIndex,
onSelected: (index) => setState(() => selectedIndex = index),
);
Layout¶
items must contain between one and five destinations. The component first
uses 80 × 54 destinations when they fit:
| Items | Preferred pill |
|---|---|
| 1 | 88 px |
| 2 | 168 px |
| 3 | 248 px |
| 4 | 328 px |
| 5 | 408 px |
The extra eight pixels are the four-pixel pill padding on both sides. When the preferred width does not fit with 16-pixel outer margins, the pill uses the available parent width minus 32 pixels and every destination receives an equal share of its content width. The same sizing rule applies to all variants from one to five destinations.
The full-width outer bar is 94 pixels high and uses the theme's bottom scrim
asset. It does not add a system safe area. Width is resolved from parent layout
constraints rather than the global screen size. Read that height from
BottomNavBar.barHeightOf when reserving space or placing a Toast above the
bar.
App Shell Integration¶
Keep the bar outside individual pages and overlay it at the application-shell
level. This lets every page use the full viewport and scroll underneath the
scrim. PageWithTopBar applies the same overlay model independently at the top
of each page.
class AppShell extends StatefulWidget {
const AppShell({super.key});
@override
State<AppShell> createState() => _AppShellState();
}
class _AppShellState extends State<AppShell> {
var selectedIndex = 0;
@override
Widget build(BuildContext context) {
final bottomContentInset = 94 + MediaQuery.paddingOf(context).bottom;
// Flutter reports the on-screen keyboard as viewInsets on both iOS and
// Android. Read it here, above Scaffold.body, then hide the overlay bar
// while the keyboard is open so it does not cover the focused field.
final keyboardVisible = MediaQuery.viewInsetsOf(context).bottom > 0;
final pages = [
PageWithTopBar(
heading: 'Home',
body: Padding(
padding: EdgeInsets.only(bottom: bottomContentInset),
child: const HomeContent(),
),
),
PageWithTopBar(
heading: 'Cards',
body: Padding(
padding: EdgeInsets.only(bottom: bottomContentInset),
child: const CardsContent(),
),
),
PageWithTopBar(
heading: 'Profile',
body: Padding(
padding: EdgeInsets.only(bottom: bottomContentInset),
child: const ProfileContent(),
),
),
];
return Scaffold(
// Keep the default true so form pages can still resize/scroll away from
// the keyboard. Only the navigation overlay is suppressed below.
body: Stack(
fit: StackFit.expand,
children: [
IndexedStack(index: selectedIndex, children: pages),
if (!keyboardVisible)
PositionedDirectional(
start: 0,
end: 0,
bottom: 0,
child: SafeArea(
top: false,
child: BottomNavBar(
items: const [
BottomNavBarItem(
icon: TsaiIcon(LucideIcons.house, size: 20),
label: 'Home',
),
BottomNavBarItem(
icon: TsaiIcon(LucideIcons.credit_card, size: 20),
label: 'Cards',
),
BottomNavBarItem(
icon: TsaiIcon(LucideIcons.user, size: 20),
label: 'Profile',
),
],
selectedIndex: selectedIndex,
onSelected: (index) => setState(() => selectedIndex = index),
),
),
),
],
),
);
}
}
The terminal body padding is page content, not part of BottomNavBar layout. It allows the final content to scroll above the overlay when necessary while still rendering behind it during normal scrolling.
Keyboard¶
Bottom navigation must not sit on top of the keyboard and cover the focused field. Prefer this shell-owned pattern:
- Own the app
Scaffoldin the shell. LeaveresizeToAvoidBottomInsetat its defaulttrueso form pages can still resize and scroll the focused input into view. - Detect the keyboard in that shell
State.buildwithMediaQuery.viewInsetsOf(context).bottom > 0. That context sits above the Scaffold body, so it still sees the real keyboard inset. (Scaffold removesviewInsetsfrom the MediaQuery it passes into its body.) - Omit the BottomNavBar overlay while the keyboard is open. When the keyboard closes, show the bar again.
Do not solve this by setting resizeToAvoidBottomInset: false on the shell
unless you intentionally want the keyboard to cover page content too. That
flag stops the body from resizing; it does not make an overlay at bottom: 0
stay under the keyboard by itself, and it often leaves form fields harder to
reach.
If the bar is wired through Scaffold.bottomNavigationBar instead of a
Stack overlay, use the same visibility check:
If the navigation shell must live inside another Scaffold's body (for
example, a catalog host), ambient viewInsets are already zero and the body
may not rebuild on keyboard changes. In that case observe metrics instead:
class _AppShellState extends State<AppShell> with WidgetsBindingObserver {
var keyboardVisible = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeMetrics() {
final next =
MediaQueryData.fromView(View.of(context)).viewInsets.bottom > 0;
if (next != keyboardVisible) {
setState(() => keyboardVisible = next);
}
}
}
Flutter unifies iOS and Android here: both platforms expose keyboard coverage
as viewInsets, even though the native soft-input policies differ. Keep
Android on adjustResize (Flutter's default activity setting) so viewInsets
update correctly.
Keep this shell inside the application's root Navigator route. Dialogs and
modal bottom sheets pushed onto that Navigator then render above the complete
shell, including BottomNavBar. With nested branch Navigators, present global
modals through the root Navigator. Do not place the bar after the Navigator
using MaterialApp.builder, because that can paint it above modal routes.
The library intentionally does not provide an app-shell widget. Page
selection, route stacks, state restoration, keyboard shell policy, and modal
ownership are application architecture concerns. Applications using a router
can implement the same layering in its shell-route facility instead of
IndexedStack.
State And Semantics¶
selectedIndex is caller-owned. Activating an item calls onSelected once
with its index; the caller must rebuild with the new selection. Selected items
expose selected-button semantics. BottomNavBarItem.semanticLabel overrides
the visible label for assistive technology when needed.
The selected item uses the semantic accent-glass surface, bright icon, and
accent text roles. Other items use secondary icon and text roles. The pill
uses surfaceGlass, the hairline border, pill radius, and glassBlur.
Pointer hover does not add a background. Pressed and selected background
changes animate with motion.interaction and respect the platform
reduced-motion setting.
The preferred and fit modes use the same semantics and interaction behavior.