Building a Custom Bottom Navigation Bar in Flutter: Design, Animation, and Architecture
Build a polished custom bottom navigation bar in Flutter: design choices, architecture, animations, accessibility, testing, and production-ready code.
Image used for representation purposes only.
Why build a custom bottom navigation bar?
A bottom navigation bar anchors core destinations, but apps often need brand-specific visuals, motion, and behavior. Flutter ships great defaults (NavigationBar for Material 3 and BottomNavigationBar for Material 2), yet custom bars let you:
- Express brand with shape, color, and motion
- Add feedback (ink, haptics, shadows) that matches product tone
- Handle complex navigation (per-tab stacks, deep links, restoration)
- Optimize performance and accessibility beyond basics
This guide walks through design choices, robust architecture, and production-ready code for a polished, custom bar.
Choosing the right foundation
Flutter offers three common paths:
- NavigationBar (Material 3): modern, built-in animations, labels, and badges. Best for most apps targeting Material 3.
- BottomNavigationBar (Material 2): stable, widely used, fewer opinions. Good if you remain on Material 2.
- Fully custom: a bespoke widget built with Container, CustomPainter, or ClipPath. Use when you need unique shapes (pill, convex, floating) or advanced animation.
Tip: prefer the highest-level widget that meets requirements; build custom only where the default falls short.
UX rules of thumb
- Keep 3–5 destinations; more harms scanability.
- Always show labels; optionally hide inactive labels only if icons are unmistakable.
- Maintain a 48dp minimum tap target.
- Preserve selection state during tab switches.
- Respect platform idioms: Android favors Material bars; iOS commonly uses CupertinoTabBar.
Minimal implementation with NavigationBar (Material 3)
If Material 3 fits your brand, start here. It provides elevation, animations, and labels for free.
import 'package:flutter/material.dart';
class M3NavExample extends StatefulWidget {
const M3NavExample({super.key});
@override
State<M3NavExample> createState() => _M3NavExampleState();
}
class _M3NavExampleState extends State<M3NavExample> {
int index = 0;
final pages = const [
Center(child: Text('Home')),
Center(child: Text('Search')),
Center(child: Text('Cart')),
Center(child: Text('Profile')),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(index: index, children: pages),
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.shopping_cart_outlined), selectedIcon: Icon(Icons.shopping_cart), label: 'Cart'),
NavigationDestination(icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), label: 'Profile'),
],
),
);
}
}
Why IndexedStack? It preserves state for each tab rather than rebuilding.
Architecture for robust navigation
A bar is only half the story; how you manage per-tab content matters for back behavior and deep links.
- Simple apps: IndexedStack with a single Navigator (as above). System back pops pages globally.
- Advanced apps: separate Navigator per tab, each with its own stack. Back pops the active tab stack first, then switches tabs when empty.
class MultiNavScaffold extends StatefulWidget {
const MultiNavScaffold({super.key});
@override
State<MultiNavScaffold> createState() => _MultiNavScaffoldState();
}
class _MultiNavScaffoldState extends State<MultiNavScaffold> {
int index = 0;
final keys = List.generate(4, (_) => GlobalKey<NavigatorState>());
Future<bool> _onWillPop() async {
final nav = keys[index].currentState!;
if (nav.canPop()) { nav.pop(); return false; }
return true; // allow system back to exit when active stack is root
}
Widget _buildOffstageNavigator(int tabIndex, Widget child) {
return Offstage(
offstage: index != tabIndex,
child: Navigator(
key: keys[tabIndex],
onGenerateRoute: (settings) => MaterialPageRoute(builder: (_) => child),
),
);
}
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: _onWillPop,
child: Scaffold(
body: Stack(children: [
_buildOffstageNavigator(0, const HomePage()),
_buildOffstageNavigator(1, const SearchPage()),
_buildOffstageNavigator(2, const CartPage()),
_buildOffstageNavigator(3, const ProfilePage()),
]),
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.shopping_cart_outlined), selectedIcon: Icon(Icons.shopping_cart), label: 'Cart'),
NavigationDestination(icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), label: 'Profile'),
],
),
),
);
}
}
This pattern ensures each tab keeps its own stack, ideal for complex flows and deep links.
Building a fully custom bar (animated pill with indicator)
Below is a custom widget with:
- Animated pill background under the active item
- Smooth color and icon transitions
- Semantic labels and haptics
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class NavItem {
final IconData icon;
final String label;
const NavItem(this.icon, this.label);
}
class CustomBottomNavBar extends StatelessWidget {
final List<NavItem> items;
final int currentIndex;
final ValueChanged<int> onTap;
final Duration duration;
final Curve curve;
final double height;
final EdgeInsets padding;
final Color background;
final Color activeColor;
final Color inactiveColor;
const CustomBottomNavBar({
super.key,
required this.items,
required this.currentIndex,
required this.onTap,
this.duration = const Duration(milliseconds: 300),
this.curve = Curves.easeOutCubic,
this.height = 72,
this.padding = const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
this.background = const Color(0xFF0B0B0C),
this.activeColor = const Color(0xFF00B4FF),
this.inactiveColor = const Color(0xFF9EA3AE),
});
@override
Widget build(BuildContext context) {
assert(items.length >= 3 && items.length <= 5);
return SafeArea(
top: false,
child: Container(
height: height,
margin: const EdgeInsets.all(12),
padding: padding,
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(color: Colors.black.withOpacity(0.25), blurRadius: 20, offset: const Offset(0, 10)),
],
),
child: LayoutBuilder(
builder: (context, constraints) {
final itemWidth = constraints.maxWidth / items.length;
return Stack(children: [
// Animated pill indicator
AnimatedPositioned(
duration: duration,
curve: curve,
left: currentIndex * itemWidth,
width: itemWidth,
top: 0,
bottom: 0,
child: Center(
child: AnimatedContainer(
duration: duration,
curve: curve,
width: itemWidth - 24,
height: 40,
decoration: BoxDecoration(
color: activeColor.withOpacity(0.15),
borderRadius: BorderRadius.circular(14),
),
),
),
),
// Items row
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [for (var i = 0; i < items.length; i++) _buildItem(context, i, itemWidth)],
),
]);
},
),
),
);
}
Widget _buildItem(BuildContext context, int i, double itemWidth) {
final selected = i == currentIndex;
final theme = Theme.of(context);
return Semantics(
container: true,
button: true,
selected: selected,
label: items[i].label,
child: InkWell(
customBorder: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
onTap: () {
HapticFeedback.selectionClick();
onTap(i);
},
child: SizedBox(
width: itemWidth,
height: 48,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(items[i].icon, color: selected ? activeColor : inactiveColor),
AnimatedSwitcher(
duration: duration,
switchInCurve: curve,
switchOutCurve: curve,
child: selected
? Padding(
key: ValueKey('label-$i'),
padding: const EdgeInsets.only(left: 8),
child: Text(
items[i].label,
style: theme.textTheme.labelLarge?.copyWith(color: activeColor, fontWeight: FontWeight.w600),
),
)
: const SizedBox.shrink(key: ValueKey('spacer')),
),
],
),
),
),
);
}
}
Usage example with preserved tab state:
class CustomNavDemo extends StatefulWidget {
const CustomNavDemo({super.key});
@override
State<CustomNavDemo> createState() => _CustomNavDemoState();
}
class _CustomNavDemoState extends State<CustomNavDemo> {
int index = 0;
final pages = const [HomePage(), SearchPage(), CartPage(), ProfilePage()];
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(index: index, children: pages),
bottomNavigationBar: CustomBottomNavBar(
items: const [
NavItem(Icons.home, 'Home'),
NavItem(Icons.search, 'Search'),
NavItem(Icons.shopping_cart, 'Cart'),
NavItem(Icons.person, 'Profile'),
],
currentIndex: index,
onTap: (i) => setState(() => index = i),
),
);
}
}
Theming and dark mode
- Derive colors from Theme extensions to sync with brand palettes.
- For dark mode, increase contrast: brighter activeColor, reduce shadow opacity, and ensure label text meets contrast ratios.
final scheme = Theme.of(context).colorScheme;
return CustomBottomNavBar(
background: scheme.surface,
activeColor: scheme.primary,
inactiveColor: scheme.onSurfaceVariant,
// ...
);
Animations that feel right
- Use implicit animations (AnimatedContainer, AnimatedPositioned, AnimatedSwitcher) for simple state changes.
- For physics-like movement, switch to AnimationController with spring Curves and lower duration for responsiveness (200–300ms typical).
- Avoid over-animating labels; reserve motion for selection feedback.
Performance tips
- Keep icons const where possible to skip rebuild work.
- Place currentIndex in a parent State to prevent rebuilding tab content unnecessarily.
- Use IndexedStack or Offstage to preserve subtree state.
- Wrap complex painters in RepaintBoundary if they animate independently.
- Prefer vector icons over raster images to reduce GPU upload.
Accessibility and internationalization
- Ensure 48dp minimum targets; add SafeArea to avoid system gesture areas.
- Provide Semantics for each destination with selected flag.
- Keep visible labels for clarity and screen readers.
- Support long labels by allowing soft wrap or truncation with tooltip.
- Handle right-to-left locales by using Directionality; layout code using Row with MainAxisAlignment.center typically adapts automatically.
Handling the keyboard, notches, and insets
- Wrap the bar in SafeArea(top: false) to respect bottom insets on devices with gesture navigation and cutouts.
- Use MediaQuery.viewPadding for additional custom spacing if you draw outside SafeArea.
- When showing text fields, consider hiding the bar: set resizeToAvoidBottomInset to true or conditionally Offstage the bar when viewInsets.bottom > 0.
Platform nuances
- Android: align with Material guidance; use ripple (InkWell) and consider haptic selection.
- iOS: consider CupertinoTabScaffold for platform fidelity, or tune your custom bar to match iOS touch feedback and blur effects using BackdropFilter.
Deep links and state restoration
- Map incoming links to a tab index. If a link targets a nested route in a tab, switch the tab first, then push within that tab’s Navigator.
- For restoration, give each Navigator a restorationScopeId and store currentIndex in RestorationMixin.
class RestorableNav extends StatefulWidget {
const RestorableNav({super.key});
@override
State<RestorableNav> createState() => _RestorableNavState();
}
class _RestorableNavState extends State<RestorableNav> with RestorationMixin {
final RestorableInt _index = RestorableInt(0);
@override
String get restorationId => 'root-scaffold';
@override
void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
registerForRestoration(_index, 'tab-index');
}
@override
void dispose() { _index.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(index: _index.value, children: const [HomePage(), SearchPage(), CartPage(), ProfilePage()]),
bottomNavigationBar: CustomBottomNavBar(
items: const [
NavItem(Icons.home, 'Home'),
NavItem(Icons.search, 'Search'),
NavItem(Icons.shopping_cart, 'Cart'),
NavItem(Icons.person, 'Profile'),
],
currentIndex: _index.value,
onTap: (i) => setState(() => _index.value = i),
),
);
}
}
Testing your navigation bar
- Widget tests: verify semantics, selection state, ink behavior, and label visibility.
- Golden tests: catch visual regressions across themes and device sizes.
- Integration tests: validate back behavior and deep-link routing.
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('Selecting a tab updates semantics and label', (tester) async {
var current = 0;
await tester.pumpWidget(MaterialApp(
home: Scaffold(
bottomNavigationBar: CustomBottomNavBar(
items: const [NavItem(Icons.home, 'Home'), NavItem(Icons.search, 'Search'), NavItem(Icons.person, 'Profile')],
currentIndex: current,
onTap: (i) => current = i,
),
),
));
await tester.tap(find.text('Search'));
await tester.pumpAndSettle();
// Verify that tapping updated selection
expect(current, 1);
});
}
Production checklist
- Tabs limited to 3–5 and labeled
- SafeArea respected; tap targets >= 48dp
- Selection preserved via IndexedStack or per-tab Navigators
- Animations under 300ms; haptics on selection
- High-contrast colors; dark mode handled
- Semantics and RTL tested
- Back behavior correct; deep links mapped to tabs
- Restoration enabled if appropriate
When to reach for packages
If your design calls for advanced shapes (convex, wave, docked with a center notch), consider evaluating community packages. Validate maintenance cadence, null-safety, and test coverage before adopting. Weigh the cost of dependency against a few dozen lines of bespoke code like the example above.
Wrap-up
A custom bottom navigation bar is a high-leverage UI investment: it guides users, carries brand, and shapes perceived performance. Start with built-in NavigationBar where possible, then layer in custom visuals and motion only where they add clarity and delight. With thoughtful architecture and testing, your bar will look great and behave impeccably across devices and platforms.
Related Posts
Flutter Typography Guide: Custom Fonts, TextThemes, and Best Practices
Learn how to add and manage custom fonts in Flutter—assets, Google Fonts, text themes, scaling, fallbacks, and best practices for readable apps.
Flutter Apple Pay and Google Pay Integration: An End-to-End Guide
A practical, end-to-end guide to adding Apple Pay and Google Pay to your Flutter app, from setup and UI to server token exchange and testing.
Flutter Text-to-Speech (TTS) Integration: A Complete Guide with Code
Integrate Flutter text-to-speech with flutter_tts: setup, voices, events, mixing audio, long text chunking, and production-ready patterns with code.