Flutter Bottom Navigation Bar with Material 3 (NavigationBar Guide)

Build a bottom navigation bar in Flutter with the Material 3 NavigationBar widget: switch screens, keep each tab's state with IndexedStack, add badges and style it to match your app.

By Yaqoob Developer · · 5 min read

  • #Flutter
  • #Navigation
  • #Material 3
  • #Flutter UI
  • #Beginners

Almost every app has a bottom navigation bar: Home, Search, Favorites, Profile. In Flutter you build it with the NavigationBar widget, the Material 3 version of the older BottomNavigationBar.

In this guide you will learn:

  • How to add a NavigationBar to a Scaffold
  • How to switch between screens when a tab is tapped
  • How to keep each tab's scroll position and state with IndexedStack
  • How to add a badge (like an unread count)
  • How to change colors, the indicator and the label behavior

If you are new to moving between screens, read Flutter navigation between screens first. Tabs work differently from pushing a new page, and this guide explains why.

Flutter has two widgets for this:

  • NavigationBar follows Material 3: a pill-shaped indicator behind the selected icon, taller bar, softer colors.
  • BottomNavigationBar is the older Material 2 design.

Material 3 is the default in Flutter, so use NavigationBar for new apps. Both work the same way: you give them a list of destinations, the selected index, and a callback for taps.

Step 1: A basic NavigationBar

import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(colorSchemeSeed: Colors.indigo),
      home: const HomeShell(),
    );
  }
}

class HomeShell extends StatefulWidget {
  const HomeShell({super.key});

  @override
  State<HomeShell> createState() => _HomeShellState();
}

class _HomeShellState extends State<HomeShell> {
  int _index = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(child: Text('Tab $_index')),
      bottomNavigationBar: NavigationBar(
        selectedIndex: _index,
        onDestinationSelected: (i) => setState(() => _index = i),
        destinations: const [
          NavigationDestination(
            icon: Icon(Icons.home_outlined),
            selectedIcon: Icon(Icons.home),
            label: 'Home',
          ),
          NavigationDestination(
            icon: Icon(Icons.search),
            label: 'Search',
          ),
          NavigationDestination(
            icon: Icon(Icons.favorite_outline),
            selectedIcon: Icon(Icons.favorite),
            label: 'Favorites',
          ),
          NavigationDestination(
            icon: Icon(Icons.person_outline),
            selectedIcon: Icon(Icons.person),
            label: 'Profile',
          ),
        ],
      ),
    );
  }
}

What each part does:

  • selectedIndex tells the bar which tab is active.
  • onDestinationSelected gives you the index of the tapped tab. You save it with setState so the screen rebuilds.
  • selectedIcon is optional. Using an outlined icon for normal and a filled icon for selected is the Material 3 look.

The bar does not change screens by itself. It only reports taps. You decide what to show in body.

Step 2: Show a different screen for each tab

Create one widget per tab and pick the right one with the index:

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: 50,
      itemBuilder: (context, i) => ListTile(title: Text('Post #$i')),
    );
  }
}

class PlaceholderPage extends StatelessWidget {
  const PlaceholderPage(this.title, {super.key});

  final String title;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Text(title, style: Theme.of(context).textTheme.headlineMedium),
    );
  }
}

Then in _HomeShellState:

static const _pages = [
  HomePage(),
  PlaceholderPage('Search'),
  PlaceholderPage('Favorites'),
  PlaceholderPage('Profile'),
];

// in build():
body: _pages[_index],

This works, but try it: scroll down the Home list, switch to Profile, and come back. The list is at the top again. Flutter destroyed the Home page when you left and built a new one when you returned.

Step 3: Keep each tab's state with IndexedStack

IndexedStack keeps all its children alive and only shows the one at index. Replace the body:

body: IndexedStack(
  index: _index,
  children: _pages,
),

Now every tab remembers its scroll position, text field contents and loaded data. This is how apps like Instagram and YouTube behave, and it is what users expect.

One thing to know: IndexedStack builds all tabs on the first frame. For four simple tabs that is fine. If a tab loads heavy data, start the loading when the tab is first opened, not in its constructor.

Step 4: Add a badge

Show an unread count on a tab with the Badge widget:

NavigationDestination(
  icon: Badge(
    label: Text('3'),
    child: Icon(Icons.notifications_outlined),
  ),
  selectedIcon: Badge(
    label: Text('3'),
    child: Icon(Icons.notifications),
  ),
  label: 'Alerts',
),

For a small dot without a number, use Badge(child: ...) with no label. Hide the badge when the count is zero with isLabelVisible: count > 0.

Step 5: Style the bar

NavigationBar takes its colors from your theme, so the easiest way to change it is a seed color in ThemeData. For more control, pass properties directly:

NavigationBar(
  selectedIndex: _index,
  onDestinationSelected: (i) => setState(() => _index = i),
  backgroundColor: Colors.white,
  indicatorColor: Colors.indigo.shade100,
  height: 70,
  labelBehavior: NavigationDestinationLabelBehavior.onlyShowSelected,
  destinations: const [
    // ...
  ],
)
  • indicatorColor is the pill behind the selected icon.
  • height changes the bar height (the default is 80).
  • labelBehavior can show all labels, only the selected label, or none.

To style every NavigationBar in the app at once, use navigationBarTheme in ThemeData instead of repeating these properties.

Step 6: Handle the Android back button

With tabs, users expect the back button to go to the Home tab first, and only close the app from Home. Wrap the Scaffold in PopScope:

@override
Widget build(BuildContext context) {
  return PopScope(
    canPop: _index == 0,
    onPopInvokedWithResult: (didPop, result) {
      if (!didPop) setState(() => _index = 0);
    },
    child: Scaffold(
      // ...
    ),
  );
}

When you are on any tab except Home, canPop is false, so back switches to Home instead of closing the app.

Common mistakes

Calling Navigator.push when a tab is tapped. That pushes a new page on top with a back arrow, and the bottom bar disappears. Tabs should change body, not push routes.

Forgetting setState. If tapping a tab does nothing, check that you save the new index inside setState.

More than five tabs. Material guidelines recommend three to five destinations. If you have more, move the extra ones to a Profile or Settings screen.

Different screens, same bar on tablets. On wide screens, a side NavigationRail looks better than a bottom bar. Use LayoutBuilder to switch between them based on screen width.

Want a ready-made design?

The bottom bar is usually the first thing users see in your app, so it is worth making it look good. Browse my Flutter UI projects for complete screens with source code, and check the social media profile UI for a profile tab you can drop into this layout.

Summary

  • Use NavigationBar for Material 3 bottom navigation.
  • Store the selected index in a StatefulWidget and update it with setState.
  • Use IndexedStack so each tab keeps its state.
  • Add Badge for counts, and PopScope so back goes to the Home tab first.

Keep reading

More articles