Flutter: BottomNavigationBar Usage
Last updated:
7 min read

BottomNavigationBar is the tab strip at the bottom of the screen that switches between an app's main sections: three to five independent, equally important destinations such as Home, Search, Cart and Profile. Unlike the TabBar at the top, it does not respond to swipes, and it represents the top-level areas of the app rather than sections of a single page. This post shows how to wire it up correctly, why page state disappears when you switch tabs, and why icons suddenly "vanish" once you add a fourth item.
Live Demo
You can try this widget in the interactive example below:
💡 If the example above doesn't load, click DartPad to run it in a new tab.
Basic Usage
Three pieces are needed: an int holding the selected tab, a bar that reads it as currentIndex, and a setState inside onTap that updates it. If any of the three is missing, the bar either does nothing or the highlight is stuck on the first item.
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _selectedIndex = 0;
static const List<Widget> _pages = [
HomePage(),
SearchPage(),
ProfilePage(),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: _pages[_selectedIndex],
bottomNavigationBar: BottomNavigationBar(
currentIndex: _selectedIndex,
onTap: (index) {
setState(() {
_selectedIndex = index;
});
},
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: 'Home',
),
BottomNavigationBarItem(
icon: Icon(Icons.search),
label: 'Search',
),
BottomNavigationBarItem(
icon: Icon(Icons.person),
label: 'Profile',
),
],
),
);
}
}BottomNavigationBar itself never changes a page; it only reports "the user tapped item 2". Which page is visible is decided by body. The distinction matters because trying to switch tabs with Navigator.push is a common mistake: tabs do not push new pages onto the stack, they swap content inside the same Scaffold. Stack-based navigation is covered in the Navigator post.
Important Properties
| Property | Description |
|---|---|
currentIndex |
Index of the selected item; must live in State |
onTap |
Callback that receives the tapped index |
items |
At least two BottomNavigationBarItems |
type |
fixed or shifting |
selectedItemColor |
Color of the selected item |
unselectedItemColor |
Color of unselected items |
showUnselectedLabels |
Show labels for unselected items |
backgroundColor |
Background color (applies to the fixed type) |
fixed vs shifting
The type parameter sets the bar's behavior, and its default depends on the number of items: three or fewer means fixed, four or more means shifting. This rule surprises beginners more than anything else about the widget.
// fixed: all items share the width equally, labels are always visible
BottomNavigationBar(
type: BottomNavigationBarType.fixed,
// ...
)
// shifting: only the selected label is shown, the background follows the item
BottomNavigationBar(
type: BottomNavigationBarType.shifting,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: 'Home',
backgroundColor: Colors.indigo, // in shifting mode each item supplies its background
),
// ...
],
)In shifting mode the unselected labels are hidden and the bar's background comes from the selected item's backgroundColor. If you do not give the items a color, you get the "white on white" look described in the mistakes section. For most apps fixed is the right choice; with four or five items, write it explicitly.
Active and Inactive Icons
The pattern Material recommends is a filled icon for the selected item and an outlined one for the rest:
BottomNavigationBarItem(
icon: Icon(Icons.home_outlined), // Inactive icon
activeIcon: Icon(Icons.home), // Active icon
label: 'Home',
)Preserving Page State with IndexedStack
The _pages[_selectedIndex] approach above has a cost: when the tab changes, the previous page is removed from the widget tree and its State object is disposed. The text typed on the search page, the scroll position of a list, an open filter, all of it resets; coming back, the page runs initState as if opened for the first time. Why that happens is explained in the lifecycle post.
IndexedStack keeps every child in the tree and paints only the one at index:
body: IndexedStack(
index: _selectedIndex,
children: const [
HomePage(),
SearchPage(),
ProfilePage(),
],
)The trade-off: all pages are built the moment the app starts. If each page fires a network request in initState, all four run at once. For lightweight pages that is fine; for heavy ones, trigger loading when the page first becomes visible, or build a small wrapper that only creates tabs that have been visited.
Adding a Badge
To show a notification or cart count in the corner of an icon, the Material 3 Badge widget is enough:
BottomNavigationBarItem(
icon: Badge(
label: Text('3'),
child: Icon(Icons.notifications),
),
label: 'Notifications',
)To hide the badge when the count is zero, use Badge.count(count: n, isLabelVisible: n > 0, child: ...); the scenario below shows it.
When to Use It and When Not To
- Use it when the app has three to five top-level areas that are independent of each other and must be reachable at any moment.
- Don't use it for more than five destinations. Items get cramped and labels unreadable. Move the extras into a Drawer or behind a "More" tab.
- Don't use it when the destinations are sections of one screen (a product's "Description / Reviews / Questions"). That is the job of the top
TabBar. - Don't use it for sequential flows (step 1, step 2, payment). A sequence moves forward on the
Navigatorstack; free movement between tabs breaks it. - Consider the Material 3
NavigationBar, which does the same job, if your project uses Material 3. How to choose and how to migrate is covered separately in the BottomNavigationBar vs NavigationBar comparison. On tablets and desktops, aNavigationRailon the side replaces the bottom bar.
Common Mistakes
1. Icons disappeared after adding the fourth item
Symptom: everything was fine with three items; after adding a fourth, labels are hidden and, in the light theme, icons turn white on a white background and become invisible.
Cause: from four items onwards the default type switches to shifting. In that mode the item color falls back to the theme's surface color (white in the light theme) and the background is taken from the items' backgroundColor; if you did not set one, icons and background end up the same color.
Fix: write type: BottomNavigationBarType.fixed. If you really want shifting, give every BottomNavigationBarItem a backgroundColor.
2. The highlight is stuck on the first tab
Symptom: tapping switches the page, but the selected item in the bar never moves.
Cause: currentIndex: 0 is hard-coded, or onTap does not call setState. The bar does not remember its own selection; on every build it shows whatever currentIndex says.
Fix: keep the index in a State field, pass that field to currentIndex, and update it with setState inside onTap.
3. The "items.length >= 2" assertion
Symptom: the app crashes at startup with 'items.length >= 2': is not true.
Cause: the items are generated from a list that turned out empty or single-element; the widget requires at least two.
Fix: do not show the bar when the list is too short (bottomNavigationBar: items.length >= 2 ? BottomNavigationBar(...) : null).
4. Search text and scroll position reset on tab change
Symptom: you type something in the Search tab, switch to Profile, come back, and the field is empty.
Cause: body: _pages[_selectedIndex] destroys the previous page on every switch.
Fix: build body with IndexedStack as shown above.
Mini Scenario: A Shop Shell with a Cart Badge
Picture a shopping app with four tabs: Home, Search, Cart and Profile. Adding a product from the Home tab must update the badge on the Cart icon, pages must keep their state while the user moves around, and on Android the back button should return to Home first instead of closing the app.
class ShopShell extends StatefulWidget {
const ShopShell({super.key});
@override
State<ShopShell> createState() => _ShopShellState();
}
class _ShopShellState extends State<ShopShell> {
int _selectedIndex = 0;
int _cartCount = 0;
void _addToCart() => setState(() => _cartCount++);
@override
Widget build(BuildContext context) {
final cartIcon = Badge.count(
count: _cartCount,
isLabelVisible: _cartCount > 0,
child: const Icon(Icons.shopping_cart_outlined),
);
return PopScope(
canPop: _selectedIndex == 0, // Only leave the app from the Home tab
onPopInvokedWithResult: (didPop, _) {
if (!didPop) setState(() => _selectedIndex = 0);
},
child: Scaffold(
body: IndexedStack(
index: _selectedIndex,
children: [
HomePage(onAddToCart: _addToCart),
const SearchPage(),
CartPage(count: _cartCount),
const ProfilePage(),
],
),
bottomNavigationBar: BottomNavigationBar(
type: BottomNavigationBarType.fixed, // 4 items: don't fall back to shifting
currentIndex: _selectedIndex,
onTap: (i) => setState(() => _selectedIndex = i),
items: [
const BottomNavigationBarItem(
icon: Icon(Icons.home_outlined),
activeIcon: Icon(Icons.home),
label: 'Home',
),
const BottomNavigationBarItem(
icon: Icon(Icons.search),
label: 'Search',
),
BottomNavigationBarItem(
icon: cartIcon,
label: 'Cart',
),
const BottomNavigationBarItem(
icon: Icon(Icons.person_outline),
activeIcon: Icon(Icons.person),
label: 'Profile',
),
],
),
),
);
}
}The cart count lives in the shell widget's State; HomePage increments it through a callback, and CartPage and the badge read the same value. As pages grow, moving that shared value into a state-management layer such as Provider is the natural next step, but the pattern stays the same: the bar knows only the index, never the data. The canPop expression in PopScope intercepts the back button whenever you are not on Home and brings the user to the first tab first, which is what Android users expect. As a final touch, you can handle i == _selectedIndex inside onTap to scroll the current list back to the top when the already-selected tab is tapped again.
Frequently Asked Questions
Why do icons disappear when BottomNavigationBar has more than three items?
Above three items the type defaults to shifting; in that mode labels are hidden and colors depend on each item's backgroundColor. Passing type: BottomNavigationBarType.fixed restores the classic look.
How do I keep a page's state when switching tabs?
Instead of picking the page directly in body, use IndexedStack. All pages stay in the tree and only the selected one is painted, so text fields and scroll positions survive.
Should I use Navigator.push to switch between tabs?
No. Tabs swap content inside the same Scaffold; Navigator.push adds a new page to the stack and the bottom bar is not visible on it. Updating currentIndex and changing body is all that is needed.
How many items should there be at most?
The Material guidelines recommend three to five. With more destinations, moving some into a Drawer or a "More" tab keeps the bar readable.
Related Posts
Flutter: BottomNavigationBar vs NavigationBar
Migrating from BottomNavigationBar to Material 3 NavigationBar: property mapping, labelBehavior, a decision guide and an adaptive shell example.
Flutter: AppBar Widget and Customization
A practical Flutter AppBar guide: title, leading, actions and bottom, the Material 3 scrolled-under color change, and fixes for common mistakes.
Flutter: BottomSheet and showModalBottomSheet
Flutter's showModalBottomSheet: returning values, isScrollControlled, useSafeArea, showDragHandle, keyboard insets, DraggableScrollableSheet, persistent sheets.