İçeriğe geç / Skip to content / Zum Inhalt

Flutter: BottomNavigationBar Usage

Ahmet Balaman

Last updated:

7 min read

FlutterBottomNavigationBarIndexedStackNavigationMaterialUI
Flutter: BottomNavigationBar Usage

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.

Passing a single page to body disposes the State of the others, while IndexedStack keeps them all in the tree and paints only the selected one

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 Navigator stack; 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, a NavigationRail on 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.

Comments