You’ve probably already built the tutorial version of a flutter bottom navigation bar. A Scaffold, three icons, a _selectedIndex, and a setState(). It works right up until the app starts behaving like a real app.
Then the cracks show. A user scrolls deep into a feed, switches tabs, and comes back to the top. A half-filled form disappears. A product detail page opens from a tab, but the navigation flow feels global instead of local. Nothing is technically broken, but the app feels cheap.
That gap is where most bottom navigation guides stop being useful. Shipping this pattern well means treating navigation as architecture, not decoration. The bar itself is easy. Preserving tab state, handling per-tab routes, avoiding accidental rebuilds, and keeping the UI responsive across feature growth is the part that matters.
Beyond the Basics of App Navigation
The common first pass looks innocent enough. You keep a list of pages, swap the body with pages[_selectedIndex], and wire the bar to update the index. For a demo app, that’s fine. For a production app, it usually creates the first UX regression your testers notice.
I’ve seen this happen most often in apps with a feed tab, a search tab, and a profile or settings tab. The team adds a long list with filters, then a draft form, then a nested detail screen. Suddenly the tab switch doesn’t feel like switching sections of one app. It feels like tearing down one screen and constructing another from scratch every time.
Bottom navigation works best when users feel like each tab is a stable place, not a disposable widget.
That stability matters because tabs represent top-level destinations. Users expect to leave one section, check another, and return to exactly where they were. If the experience resets on every tap, the bottom bar becomes a source of friction instead of orientation.
A good implementation solves a few practical problems at once:
- State retention: scroll position, draft input, and in-progress UI state remain intact.
- Local navigation flow: pushing a detail page from one tab doesn’t hijack the whole app shell.
- Scalability: adding new destinations doesn’t trigger visual surprises or force a rewrite.
- Performance discipline: hidden tabs stay available without turning the widget tree into a mess.
That’s the difference between “it switches screens” and “it feels native.” The rest of the work is about choosing patterns that hold up once the app grows.
Building Your First Flutter Bottom Navigation Bar
A solid bottom bar starts with the native widget. Flutter’s built-in BottomNavigationBar is still the right default for many apps because it’s predictable, stable, and easy to reason about.


The baseline implementation
Here’s a clean starting point that’s fine for a simple app shell:
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(
title: 'Bottom Nav Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: false,
),
home: const MainShell(),
);
}
}
class MainShell extends StatefulWidget {
const MainShell({super.key});
@override
State<MainShell> createState() => _MainShellState();
}
class _MainShellState extends State<MainShell> {
int _selectedIndex = 0;
static const List<Widget> _pages = <Widget>[
HomeTab(),
SearchTab(),
ProfileTab(),
];
void _onItemTapped(int index) {
if (_selectedIndex == index) return;
setState(() {
_selectedIndex = index;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: _pages[_selectedIndex],
bottomNavigationBar: BottomNavigationBar(
currentIndex: _selectedIndex,
onTap: _onItemTapped,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home_outlined),
activeIcon: Icon(Icons.home),
label: 'Home',
tooltip: 'Go to home',
),
BottomNavigationBarItem(
icon: Icon(Icons.search_outlined),
activeIcon: Icon(Icons.search),
label: 'Search',
tooltip: 'Search content',
),
BottomNavigationBarItem(
icon: Icon(Icons.person_outline),
activeIcon: Icon(Icons.person),
label: 'Profile',
tooltip: 'Open profile',
),
],
),
);
}
}
class HomeTab extends StatelessWidget {
const HomeTab({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(child: Text('Home Tab')),
);
}
}
class SearchTab extends StatelessWidget {
const SearchTab({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(child: Text('Search Tab')),
);
}
}
class ProfileTab extends StatelessWidget {
const ProfileTab({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(child: Text('Profile Tab')),
);
}
}
This gets the core pieces right:
itemsdefines each destination with an icon and label.currentIndexkeeps the visual selection in sync with app state.onTapupdates the selected tab.activeIcongives you better selected-state feedback without custom painting.
The properties that actually matter
A lot of basic tutorials list every property. That isn’t useful. In practice, only a handful shape the behavior you care about early on.
| Property | Why it matters |
|---|---|
items | Defines the destinations and labels users rely on |
currentIndex | Controls selected state. If this drifts, the UI lies |
onTap | Your tab switch handler. Keep it small and predictable |
type | Important when item count changes and layout behavior shifts |
elevation | A visual and performance trade-off on some devices |
The _selectedIndex pattern isn’t optional. Flutter’s own usage pattern depends on storing the selected tab in state, assigning that value to currentIndex, and updating it through an onTap callback. If you skip that control loop, the bar won’t reflect selection correctly.
The item count rule that trips teams later
Flutter’s BottomNavigationBar is designed for three to five navigation items, and the widget automatically changes behavior based on item count according to the Flutter API docs for BottomNavigationBar. The docs note that the bar uses fixed when there are fewer than four items and switches to BottomNavigationBarType.shifting when there are four or more items.
That automatic shift catches teams when product adds “just one more tab.” The layout, label behavior, and overall feel can change without anyone touching styling code.
Practical rule: If the product roadmap suggests a sixth top-level destination, don’t squeeze it into the bar. Rework the navigation model early.
Flutter’s docs also recommend moving toward side navigation on larger screens when the app outgrows this pattern. That’s a design constraint worth respecting. A crowded bottom bar almost always creates more confusion than convenience.
A better foundation than most starter examples
Even for a first implementation, a few habits pay off:
- Keep page widgets separate: don’t inline giant tab bodies directly in the shell.
- Add tooltips now: accessibility gets skipped unless it’s built in from the start.
- Use stable labels: changing labels between screens weakens orientation.
- Plan destination hierarchy early: bottom tabs should represent peer sections, not random shortcuts.
If your app is small and stateless, the baseline version is enough for a while. The moment a tab contains meaningful UI state, the body-swapping approach starts costing you. That’s where most apps need a different structure.
Mastering State Management and Page Preservation
A user fills half a checkout form, taps another tab to check saved items, then comes back to an empty screen. That failure usually starts with one innocent line of code.


The tab-switching pattern that breaks state
A lot of starter examples do this:
body: _pages[_selectedIndex]
It works for a demo. It fails fast in a real app.
With direct body replacement, Flutter swaps one subtree for another. If the tab owns local UI state, that state can be lost when the widget is rebuilt from scratch. Teams usually notice this after QA reports that forms reset, scroll positions jump, search filters disappear, or a video starts over after every tab switch.
The fix is architectural, not cosmetic.
Use IndexedStack as the default for persistent tabs
If each tab is a top-level destination, keep those destinations mounted. IndexedStack is usually the right starting point because it preserves each tab's widget tree while showing only the active child.
import 'package:flutter/material.dart';
class MainShell extends StatefulWidget {
const MainShell({super.key});
@override
State<MainShell> createState() => _MainShellState();
}
class _MainShellState extends State<MainShell> {
int _selectedIndex = 0;
late final List<Widget> _pages = [
const FeedTab(),
const DiscoverTab(),
const AccountTab(),
];
void _onItemTapped(int index) {
if (index == _selectedIndex) return;
setState(() {
_selectedIndex = index;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _selectedIndex,
children: _pages,
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _selectedIndex,
onTap: _onItemTapped,
elevation: 0,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.dynamic_feed_outlined),
activeIcon: Icon(Icons.dynamic_feed),
label: 'Feed',
tooltip: 'Open feed',
),
BottomNavigationBarItem(
icon: Icon(Icons.explore_outlined),
activeIcon: Icon(Icons.explore),
label: 'Discover',
tooltip: 'Open discover',
),
BottomNavigationBarItem(
icon: Icon(Icons.account_circle_outlined),
activeIcon: Icon(Icons.account_circle),
label: 'Account',
tooltip: 'Open account',
),
],
),
);
}
}
This solves the class of bugs that make tabbed apps feel fragile. Hidden tabs stay mounted, so text fields, controllers, scrollables, and in-progress interactions survive a tab change.
If a tab contains a form, media playback, filter state, or a long list, preserve it unless profiling gives you a reason not to.
What IndexedStack fixes, and what it does not
IndexedStack keeps widgets alive. That is both its value and its cost.
The trade-off is memory. Three lightweight tabs are rarely a problem. Three tabs that each host large lists, multiple streams, expensive charts, and nested navigators can become expensive on lower-end devices. I still start with IndexedStack in most production apps, but I profile early if a tab tree is heavy or opens platform views.
It also does not replace actual state management. Preserving a widget tree is not the same as managing business state correctly. Keep shell concerns, such as _selectedIndex, local to the shell. Keep API state, caching, auth, and domain logic in the state layer your app already uses. If you need a refresher on choosing that layer, this guide to Flutter state management patterns is a good reference.
Keep tab roots stable
I see one mistake often. Developers use IndexedStack, then recreate the pages list inside build(). That undermines the point.
Keep page instances stable:
- Create the tab pages once, not on every rebuild.
- Avoid doing network requests in
build(). - Keep tab-specific controllers inside the tab, not in the shell, unless the shell owns them.
- Use tab root widgets with predictable identity.
The late final List<Widget> _pages pattern in the example above is simple and reliable.
Scroll position needs its own help sometimes
IndexedStack preserves mounted tabs, but scroll behavior can still get messy if the widget tree changes shape, especially after refactors. PageStorageKey is still worth using for scroll-heavy tabs and nested scroll views.
class FeedTab extends StatelessWidget {
const FeedTab({super.key});
@override
Widget build(BuildContext context) {
return ListView.builder(
key: const PageStorageKey<String>('feed-list'),
itemCount: 50,
itemBuilder: (context, index) {
return ListTile(
title: Text('Feed item $index'),
subtitle: const Text('Scroll away, switch tabs, and come back.'),
);
},
);
}
}
That key gives Flutter a stable place to store page-specific UI state, including scroll offset. It is a small addition, but it prevents a lot of annoying regressions.
A quick visual walkthrough helps if you want to compare common tab behaviors in motion:
Production pitfalls that basic tutorials skip
State preservation gets harder once each tab has real responsibilities.
A few patterns deserve extra caution:
| Problem | Usually caused by | Better approach |
|---|---|---|
| Form input disappears | Body replacement by index | Keep tab page alive with IndexedStack |
| Feed jumps to top | Scrollable rebuilt from scratch | Preserve page and use stable scroll keys |
| Animations restart | Tab subtree destroyed | Keep widget mounted |
| UI feels sluggish | Heavy rebuilds and visual effects | Reduce rebuild scope and profile tab trees |
Two more pitfalls show up often in shipped apps.
First, duplicate data loading. A preserved tab should not refetch every time it becomes visible unless the product specifically wants refresh-on-return behavior. Tie fetches to lifecycle or user intent, not to every rebuild.
Second, mixed responsibilities. If the shell manages selected index, tab navigation stack, search query, and API data all in one StatefulWidget, maintenance gets ugly fast. Keep the shell thin.
A practical default for shipped apps
For most apps with three to five top-level destinations, this setup holds up well:
BottomNavigationBarin a shell widgetIndexedStackfor tab preservation- stable tab page instances
- local UI state inside each tab
- shared business state in Provider, Riverpod, Bloc, or the pattern your team already uses
- profiling before optimizing away persistence
That combination gives users continuity and gives the codebase room to grow. Bottom navigation feels simple in tutorials. In production, the work starts when users expect every tab to remember exactly where they left it.
Customization and Advanced Styling Techniques
The default bottom bar is serviceable, but very few shipped apps keep it untouched. Good customization isn’t about making the bar flashy. It’s about making selection obvious, aligning the component with the rest of the system, and handling small UI details that product teams always request later.


Recipe for a cleaner visual hierarchy
If users can’t tell which tab is active at a glance, the bar is underdesigned. Start by separating active and inactive states clearly.
BottomNavigationBar(
currentIndex: _selectedIndex,
onTap: _onItemTapped,
backgroundColor: Colors.white,
selectedItemColor: Colors.indigo,
unselectedItemColor: Colors.grey.shade600,
selectedFontSize: 12,
unselectedFontSize: 12,
showUnselectedLabels: true,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home_outlined),
activeIcon: Icon(Icons.home),
label: 'Home',
tooltip: 'Open home',
),
BottomNavigationBarItem(
icon: Icon(Icons.favorite_border),
activeIcon: Icon(Icons.favorite),
label: 'Saved',
tooltip: 'Open saved items',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings_outlined),
activeIcon: Icon(Icons.settings),
label: 'Settings',
tooltip: 'Open settings',
),
],
)
The activeIcon property is one of the easiest wins in this widget. It gives users an immediate, familiar state change without needing a custom painter or third-party package.
Small state cues matter more than decorative styling. Users notice clarity before they notice polish.
Add a badge without replacing the whole widget
Sooner or later, one tab needs a badge. Messages, cart items, alerts, pending approvals. This doesn’t require a package.
Use a Stack around the icon:
BottomNavigationBarItem(
label: 'Inbox',
tooltip: 'Open inbox',
icon: Stack(
clipBehavior: Clip.none,
children: [
const Icon(Icons.mail_outline),
Positioned(
right: -6,
top: -4,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(10),
),
constraints: const BoxConstraints(
minWidth: 18,
minHeight: 18,
),
child: const Text(
'3',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
activeIcon: Stack(
clipBehavior: Clip.none,
children: [
const Icon(Icons.mail),
Positioned(
right: -6,
top: -4,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(10),
),
constraints: const BoxConstraints(
minWidth: 18,
minHeight: 18,
),
child: const Text(
'3',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
)
For larger teams, I usually wrap that into a reusable widget like BadgedNavIcon. It keeps BottomNavigationBarItem declarations readable and avoids duplicated badge layout code.
Styling choices that work and choices that age badly
Some customization decisions improve usability. Others create maintenance work fast.
What tends to work well
- Consistent icon family: outlined inactive, filled active.
- Moderate color contrast: enough separation without neon overload.
- Visible labels: especially for first-time or infrequent users.
- Theme-driven values: centralize colors and text sizing.
What usually backfires
- Over-animated tab icons: cute in demos, distracting in use.
- Tiny labels: they reduce clarity more than they improve aesthetics.
- Per-tab background changes: they make the shell feel unstable.
- Complex handmade shapes too early: they’re hard to maintain across design revisions.
If your team needs heavier visual treatment, custom painting can be the right tool. This article on Flutter CustomPaint techniques is helpful when you move beyond standard widget composition.
Keep the shell readable
A styled bottom bar can still be maintainable if the shell stays simple. This is one pattern I like in production:
class AppNavItem {
final String label;
final String tooltip;
final Widget icon;
final Widget activeIcon;
const AppNavItem({
required this.label,
required this.tooltip,
required this.icon,
required this.activeIcon,
});
BottomNavigationBarItem toBarItem() {
return BottomNavigationBarItem(
icon: icon,
activeIcon: activeIcon,
label: label,
tooltip: tooltip,
);
}
}
That gives your product and design teams room to evolve icon states without turning the scaffold into a wall of repeated widget literals.
Handling Nested Navigation and Per-Tab Routing
Once a tab can push deeper screens, a flat shell stops being enough. This is the point where many apps start feeling wrong even though the bottom bar still looks correct.
The problem is simple. A user opens the Search tab, taps an item, then proceeds to a detail page. If that push happens on the root navigator, the entire app transitions globally. The user no longer feels like they are inside Search. They feel like they left the tab.
Each tab needs its own navigation stack
The scalable fix is to give every tab an independent Navigator. Pair that with IndexedStack, and each tab keeps both its screen state and its own route history.
A practical structure looks like this:
import 'package:flutter/material.dart';
class MainShell extends StatefulWidget {
const MainShell({super.key});
@override
State<MainShell> createState() => _MainShellState();
}
class _MainShellState extends State<MainShell> {
int _selectedIndex = 0;
final _navigatorKeys = <GlobalKey<NavigatorState>>[
GlobalKey<NavigatorState>(),
GlobalKey<NavigatorState>(),
GlobalKey<NavigatorState>(),
];
void _onTap(int index) {
if (_selectedIndex == index) {
_navigatorKeys[index].currentState?.popUntil((route) => route.isFirst);
return;
}
setState(() {
_selectedIndex = index;
});
}
Future<bool> _onWillPop() async {
final currentNavigator = _navigatorKeys[_selectedIndex].currentState!;
if (currentNavigator.canPop()) {
currentNavigator.pop();
return false;
}
return true;
}
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: _onWillPop,
child: Scaffold(
body: IndexedStack(
index: _selectedIndex,
children: [
_TabNavigator(
navigatorKey: _navigatorKeys[0],
rootPage: const HomeRootPage(),
),
_TabNavigator(
navigatorKey: _navigatorKeys[1],
rootPage: const SearchRootPage(),
),
_TabNavigator(
navigatorKey: _navigatorKeys[2],
rootPage: const ProfileRootPage(),
),
],
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _selectedIndex,
onTap: _onTap,
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Search'),
BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
],
),
),
);
}
}
class _TabNavigator extends StatelessWidget {
final GlobalKey<NavigatorState> navigatorKey;
final Widget rootPage;
const _TabNavigator({
required this.navigatorKey,
required this.rootPage,
});
@override
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
onGenerateRoute: (settings) {
return MaterialPageRoute(builder: (_) => rootPage);
},
);
}
}
This gives each tab a private stack. Search can push a detail route, Profile can open settings, and Home can stay untouched.
Why this pattern scales better
A per-tab navigator solves several product-level problems at once:
- Back behaves locally: pressing back inside a tab pops that tab’s route stack first.
- Switching tabs feels stable: users return to the exact screen they left.
- Deep flows stay isolated: a checkout or detail path doesn’t take over unrelated tabs.
- Reselect behavior becomes useful: tapping the active tab can pop to root.
Users don’t think in terms of navigators. They think in terms of sections. Your routing should match that mental model.
Native Navigator or router package
You can build this pattern directly with nested Navigator widgets, and for many apps that’s enough. If the app also needs declarative routing, deep linking, or more complex shell behavior, router packages provide a cleaner abstraction on top.
For teams moving toward router-driven architecture, it’s worth reviewing patterns around Flutter navigation bar design and app routing. In larger apps, go_router often becomes the better long-term fit because it formalizes shell and nested route behavior instead of leaving it as hand-rolled imperative logic.
The key is to avoid mixing patterns carelessly. If your shell is local-state driven but your detail routes are half global and half nested, the app will feel inconsistent no matter how nice the UI looks.
Comparing Native vs Package-Based Solutions
There’s no universal winner here. The native BottomNavigationBar is often enough, but not always enough. The right choice depends on whether you need simple top-level switching or a full shell architecture with persistent stacks and route-aware behavior.


Bottom Navigation Bar Solutions Comparison
| Feature | Native BottomNavigationBar | persistent_bottom_nav_bar | go_router (StatefulShellRoute) |
|---|---|---|---|
| Setup effort | Low | Medium | Medium to high |
| State preservation | Manual, usually with IndexedStack | Built into package patterns | Strong when shell routes are configured well |
| Nested navigation | Manual with nested Navigator widgets | Simplified for common cases | Strong fit for route-driven apps |
| Visual customization | Good for standard designs | Often includes extra animation patterns | Depends on how you build shell UI |
| Deep linking support | Manual | Limited compared with router-first setups | Strong fit for URL-aware navigation |
| Boilerplate | Low at first, grows with complexity | Lower for some common app shells | More upfront structure, less ad hoc scaling pain |
| Best fit | Small to medium apps | Teams wanting faster persistent-tab behavior | Larger apps with serious routing needs |
How I choose in practice
If the app has a few stable tabs and modest route depth, I stay native. Flutter’s own widget gives me direct control and fewer moving parts. It’s easier to debug, easier to theme, and easier for a new team member to understand.
If the app needs persistent tab stacks quickly and the team values convenience over low-level control, a package like persistent_bottom_nav_bar can shorten setup time. The trade-off is dependency behavior you didn’t write and may eventually need to work around.
If routing is already becoming a first-class concern, go_router is usually the cleaner long-term path. It handles shell-style navigation with stronger structure, which matters once deep links, guarded routes, or web support enter the picture.
Pick the simplest tool that matches your routing complexity today, but don’t ignore the complexity you already know is coming.
The mistake isn’t choosing native. The mistake is forcing native body swapping to carry responsibilities that belong to a shell architecture or router.
Final Performance and Accessibility Tips
Before you ship a flutter bottom navigation bar, run a quick quality pass. Most production issues aren’t dramatic. They’re small cuts that make the app feel less reliable.
A short checklist that catches common problems
- Preserve tab state: if users expect continuity, keep tabs alive instead of swapping raw bodies.
- Respect item limits: bottom navigation is easiest to use when destinations stay within the intended range described earlier.
- Test item-count changes: adding another tab can alter layout behavior and selected-state presentation.
- Trim unnecessary visual cost: if the design doesn’t need depth, test a flatter bar.
- Use descriptive labels and tooltips: screen readers and first-time users both benefit.
- Make active state obvious: rely on icon and color changes that are readable, not subtle.
- Handle back navigation deliberately: especially once each tab has its own route stack.
Accessibility deserves more attention than it usually gets in navigation work. Labels should be plain, not clever. Tooltips should describe the destination clearly. Icons should reinforce the label, not replace it.
A bottom bar is one of the few UI elements users touch constantly. When it’s stable, readable, and fast, the whole app feels better.
Flutter Geek Hub publishes practical Flutter guides for developers who care about shipping, not just prototyping. If you want more deep dives on navigation, state management, UI performance, and real-world Flutter architecture, explore Flutter Geek Hub.


















