A bottom navigation bar in Flutter is a persistent row of icons anchored to the screen's bottom edge that lets users switch between an app's top-level destinations without leaving the current screen.
Flutter ships two official widgets for it – NavigationBar (Material 3) and BottomNavigationBar (Material 2) – plus packages for curved, floating, or animated variants. It differs from TabBar, which switches content within a page, not between an app's destinations.
NavigationBar for Material 3 and BottomNavigationBar for Material 2.Scaffold.bottomNavigationBar slot.curved_navigation_bar and stylish_bottom_bar add curved, floating, or animated styles.Bottom Navigation Bar in Flutter sits inside Scaffold's bottomNavigationBar slot, alongside appBar, body, and floatingActionButton. Selecting a destination doesn't push a new route – onDestinationSelected (NavigationBar) or onTap (BottomNavigationBar) fires with an index, and the parent widget swaps body to match, typically via IndexedStack so every tab keeps its own scroll position and state.
This is why bottom navigation bars pair badly with Navigator.push for tab switching: pushing a route replaces the whole Scaffold, bar included, instead of just the body.
NavigationBar renders Material 3's pill-shaped indicator behind the active icon; BottomNavigationBar renders a colored icon and label with no indicator shape. NavigationBar's appearance – indicator shape, colors, height – is driven by ColorScheme and Theme.useMaterial3, which defaults to true since Flutter 3.16; apps still on Material 2 theming will see a different look.
NavigationBar is the Material 3 default and the widget to reach for first. This example switches between two screens using IndexedStack to preserve each tab's state.
import 'package:flutter/material.dart';
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: IndexedStack(
index: _index,
children: const [HomeScreen(), ProfileScreen()],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _index,
onDestinationSelected: (i) => setState(() => _index = i),
destinations: const [
NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
],
),
);
}
}selectedIndex and IndexedStack's index stay in sync, so switching tabs never rebuilds the other screens from scratch.
Packages like curved_navigation_bar replace the whole bar with a custom-painted, animated shape – useful when "curved" or "floating" is a real design requirement, not achievable with NavigationBar styling alone.
import 'package:flutter/material.dart';
import 'package:curved_navigation_bar/curved_navigation_bar.dart';
class CurvedHomeShell extends StatefulWidget {
const CurvedHomeShell({super.key});
@override
State<CurvedHomeShell> createState() => _CurvedHomeShellState();
}
class _CurvedHomeShellState extends State<CurvedHomeShell> {
int _page = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
bottomNavigationBar: CurvedNavigationBar(
index: _page,
backgroundColor: Colors.blueAccent,
items: const [
Icon(Icons.home, size: 30),
Icon(Icons.search, size: 30),
Icon(Icons.person, size: 30),
],
onTap: (i) => setState(() => _page = i),
),
body: Container(color: Colors.blueAccent),
);
}
}The animation curve and duration are the package's, not Flutter's – animationCurve and animationDuration parameters control the shape's motion, separate from NavigationBar's own transition system.
BottomAppBar with CircularNotchedRectangle and a docked FAB.Drawer instead.TabBar with TabBarView instead.NavigationBar and BottomNavigationBar are Material widgets; use CupertinoTabBar instead for an iOS-styled bottom tab bar.| NavigationBar (M3) | BottomNavigationBar (M2) |
|---|---|
Spec | |
| Current M3 default | M2, still supported |
Selection style | |
| Pill indicator | Colored icon + label |
Item type | |
| NavigationDestination | BottomNavigationBarItem |
Type modes | |
| None, all items shown | fixed or shifting |
Callback | |
| onDestinationSelected | onTap |
Best for | |
| New M3 apps | Legacy code, custom-styled bars |
Verdict: default to NavigationBar for new M3 screens; keep BottomNavigationBar only where an app already relies on its shifting type or custom theming.
body with IndexedStack, not Navigator – state and scroll position survive tab switches.label, even with labelBehavior: onlyShowSelected – NavigationDestination.tooltip defaults to the label text and stays available to screen readers (as long-press tooltip and accessibility label) regardless of whether labelBehavior hides the label visually.NavigationBar for new Material 3 screens – it's the current default, not BottomNavigationBar.NavigationBar's indicatorColor and labelTextStyle cover most brand theming without one.Navigator.push on tab tap. This replaces the whole Scaffold, hiding the bar; swap body via IndexedStack or a similar index-driven widget instead.BottomNavigationBar in a Material 3 app. NavigationBar is the current spec's widget; BottomNavigationBar is Material 2 and won't pick up M3 theming automatically.type explicitly on BottomNavigationBar with 4+ items. It silently switches to shifting, which resizes and recolors items in a way that surprises most first-time users.NavigationDestination.label must still be provided even when labelBehaviour hides the label visually. NavigationDestination.tooltip defaults to the label text, so you normally do not need to set it separately. If you intentionally need different tooltip text, it can be overridden.IndexedStack keeps every tab's widget alive off-screen, preserving scroll position and form state – the tradeoff is that all tabs are built and kept in memory, so keep off-screen tabs lightweight or lazy-load their data.NavigationDestination (Material 3) and BottomNavigationBarItem (Material 2) labels remain available to accessibility services even when hidden visually, so screen readers can still announce them. For NavigationDestination, labelBehavior controls visual label visibility; BottomNavigationBar uses showSelectedLabels and showUnselectedLabels instead. Both components require a label.
Starting with Android 15, edge-to-edge becomes the default - bringing a modern, immersive feel to apps. In this article, our Flutter developer shows how to handle system bars in Flutter across Android versions and prepare your UI for Android 16, where edge-to-edge will be mandatory.

Flutter ships with plenty of high-quality widgets, layouts, and themes that developers can use to speed up the whole creation process. A great example of custom widgets made in Flutter is the Placement Wheel developed for one of our clients. See how to do it.