Custom Bottom Navigation Bar Flutter: Complete Guide to Complex State and Nested Navigation

The best way to implement a custom bottom navigation bar in Flutter with complex state and nested navigation is to use an IndexedStack with separate Navigator widgets for each tab, managed by a top-level state controller like ChangeNotifier or Riverpod.

This architecture, as implemented in the flutter/flutter repository, separates UI rendering from state management while preserving off-screen tab states. By combining the framework's core navigation primitives with a custom state model, you can build a bottom navigation bar flutter solution that handles deep linking, independent route stacks, and complex data persistence across tab switches.

Architecture Overview: Three Core Concerns

A production-ready bottom navigation solution requires separating three distinct responsibilities:

  1. UI Rendering – The visual bar component that handles taps and displays active states
  2. State Management – The controller that tracks selected indices and per-tab data
  3. Nested Navigation – Independent route stacks for each tab that persist across switches

According to the Flutter framework source code, the default BottomNavigationBar implementation lives in packages/flutter/lib/src/material/bottom_navigation_bar.dart. While you can subclass or compose around this widget for custom styling, the critical architectural decisions involve how you manage navigation state and preserve widget trees when users switch tabs.

State Management Strategies

For complex state scenarios—such as preserving scroll positions, form data, or async loading status—you must keep navigation state outside the widget tree. The flutter/flutter source provides several patterns in packages/flutter/lib/src/foundation/change_notifier.dart that serve as the foundation for these solutions.

Provider / ChangeNotifier offers a lightweight, framework-provided approach. Create a model class that extends ChangeNotifier to hold the selected index and any per-tab data:

class TabState extends ChangeNotifier {
  int _selectedIndex = 0;
  int get selectedIndex => _selectedIndex;

  final List<GlobalKey<NavigatorState>> _navigatorKeys = List.generate(
    3,
    (_) => GlobalKey<NavigatorState>(),
  );

  List<GlobalKey<NavigatorState>> get navigatorKeys => _navigatorKeys;

  void selectTab(int index) {
    if (index == _selectedIndex) {
      // Pop to first route if re-tapped
      _navigatorKeys[index]
          .currentState
          ?.popUntil((route) => route.isFirst);
    } else {
      _selectedIndex = index;
      notifyListeners();
    }
  }
}

Riverpod provides more granular recomposition and testability for larger applications, while Bloc / Cubit works well if your codebase already uses the Bloc library. Regardless of the package choice, the state model should expose the selected index and provide methods to handle tab selection logic, including the common "pop to root" behavior when re-tapping an active tab.

Implementing Nested Navigation with IndexedStack

The key to nested navigation in a bottom navigation bar flutter implementation is using separate Navigator widgets for each tab. The Navigator class, defined in packages/flutter/lib/src/widgets/navigator.dart, maintains its own route stack when provided with a unique GlobalKey.

To preserve the state of off-screen tabs—including their navigation stacks and scroll positions—wrap the navigators in an IndexedStack from packages/flutter/lib/src/widgets/indexed_stack.dart:

IndexedStack(
  index: tabState.selectedIndex,
  children: List.generate(
    tabState.navigatorKeys.length,
    (i) => Navigator(
      key: tabState.navigatorKeys[i],
      onGenerateRoute: (settings) => MaterialPageRoute(
        builder: (_) => TabRootScreen(tabIndex: i),
      ),
    ),
  ),
)

This pattern keeps all tab widgets alive in the widget tree while only displaying the active index, avoiding the route destruction that occurs with simple conditional rendering.

Building the Custom UI Component

While the architecture handles state and navigation, the visual component can range from a styled BottomNavigationBar to a completely custom widget. The framework's Scaffold widget, located in packages/flutter/lib/src/material/scaffold.dart, positions the bottomNavigationBar property at the bottom of the screen regardless of content height.

For a custom implementation, create a widget that accepts currentIndex and onTap parameters:

class CustomBottomBar extends StatelessWidget {
  final int currentIndex;
  final ValueChanged<int> onTap;

  const CustomBottomBar({
    super.key,
    required this.currentIndex,
    required this.onTap,
  });

  @override
  Widget build(BuildContext context) {
    // Extend this to add badges, animated backgrounds, or curved shapes
    return BottomNavigationBar(
      currentIndex: currentIndex,
      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',
        ),
      ],
    );
  }
}

You can modify this to use custom painters, animated containers, or platform-specific designs while maintaining the same functional interface.

Production-Ready Implementation

Combine these elements in your main application structure. First, wrap the app with a provider:

ChangeNotifierProvider(
  create: (_) => TabState(),
  child: const MyApp(),
);

Then implement the scaffold that wires everything together:

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Consumer<TabState>(
        builder: (context, tabState, _) => Scaffold(
          body: IndexedStack(
            index: tabState.selectedIndex,
            children: List.generate(
              tabState.navigatorKeys.length,
              (i) => Navigator(
                key: tabState.navigatorKeys[i],
                onGenerateRoute: (settings) => MaterialPageRoute(
                  builder: (_) => TabRootScreen(tabIndex: i),
                ),
              ),
            ),
          ),
          bottomNavigationBar: CustomBottomBar(
            currentIndex: tabState.selectedIndex,
            onTap: tabState.selectTab,
          ),
        ),
      ),
    );
  }
}

Individual tabs can then push routes independently without affecting other tabs:

class TabRootScreen extends StatelessWidget {
  final int tabIndex;
  const TabRootScreen({super.key, required this.tabIndex});

  @override
  Widget build(BuildContext context) {
    final titles = ['Home', 'Search', 'Profile'];
    return Scaffold(
      appBar: AppBar(title: Text(titles[tabIndex])),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            Navigator.of(context).push(
              MaterialPageRoute(
                builder: (_) => DetailScreen(tabIndex: tabIndex),
              ),
            );
          },
          child: const Text('Open Detail'),
        ),
      ),
    );
  }
}

Summary

  • Use IndexedStack to preserve the widget state and navigation stacks of off-screen tabs in your bottom navigation bar flutter implementation.
  • Maintain separate Navigator widgets with unique GlobalKey<NavigatorState> instances for each tab to enable independent route management.
  • Extract state management into a ChangeNotifier, Riverpod provider, or Bloc to handle complex data persistence and tab selection logic outside the UI layer.
  • Reference packages/flutter/lib/src/material/bottom_navigation_bar.dart when extending or customizing the visual appearance of the navigation bar.
  • Implement "pop to root" behavior by checking if the tapped index matches the current index and calling popUntil((route) => route.isFirst) on the appropriate navigator key.

Frequently Asked Questions

How do I preserve scroll position when switching tabs in Flutter?

Use an IndexedStack as the body of your scaffold rather than conditionally rendering widgets based on the selected index. Because IndexedStack maintains all children in the widget tree simultaneously (as implemented in packages/flutter/lib/src/widgets/indexed_stack.dart), scroll positions, form field values, and animation states remain intact when users switch between tabs.

Can I use Navigator 2.0 or GoRouter with a custom bottom navigation bar?

Yes, but you must configure separate router delegates for each tab or use a single router with careful path parsing. When using Navigator 2.0, each tab's Navigator widget can maintain its own pages list and navigation state. For GoRouter, consider using StatefulShellRoute which internally manages multiple navigators similar to the IndexedStack pattern described above.

How do I handle deep linking to a specific tab and route?

Parse the deep link path in your top-level state management before the UI builds. Update the TabState selected index to the appropriate tab, then use the navigatorKeys list to access the specific tab's navigator state and push the target route. The Navigator API in packages/flutter/lib/src/widgets/navigator.dart provides pushNamed or push methods accessible through navigatorKeys[index].currentState.

What is the performance impact of using IndexedStack with multiple Navigators?

Since IndexedStack renders all children (including off-screen tabs) in the layout phase, memory usage scales with the complexity of each tab's widget tree. However, this trade-off enables instant tab switching and state preservation. For applications with many heavy tabs, consider lazy-loading tab content or using Offstage widgets combined with manual state restoration logic to balance memory usage against navigation speed.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →