Reader Stacks

Tab Bars in Flutter: Bottom Navigation and Top Tabs

Bottom navigation switches between entirely separate top-level screens; a top TabBar switches between views within one single screen — mixing up which one a given navigation need actually calls for is a common early mistake.

Tab Bars in Flutter: Bottom Navigation and Top Tabs

Bottom navigation and a top TabBar solve genuinely different navigation problems in Flutter — bottom navigation switches between separate top-level screens (Home, Search, Profile), while a top TabBar switches between views within one single screen.

Bottom navigation bar

int _currentIndex = 0;

final List _screens = [HomeScreen(), SearchScreen(), ProfileScreen()];

Scaffold(
  body: _screens[_currentIndex],
  bottomNavigationBar: BottomNavigationBar(
    currentIndex: _currentIndex,
    onTap: (index) {
      setState(() {
        _currentIndex = index;
      });
    },
    items: const [
      BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
      BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Search'),
      BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
    ],
  ),
)

Swapping the body widget based on _currentIndex is the standard pattern — each tab represents a genuinely distinct screen, and keeping them in a simple list indexed by the current selection keeps the switching logic straightforward.

Customizing bottom navigation's appearance

BottomNavigationBar(
  type: BottomNavigationBarType.fixed,
  selectedItemColor: Colors.deepPurple,
  unselectedItemColor: Colors.grey,
  currentIndex: _currentIndex,
  onTap: (index) => setState(() => _currentIndex = index),
  items: const [ /* ... */ ],
)

type: BottomNavigationBarType.fixed keeps all tab labels visible regardless of how many tabs exist — without it, Flutter's default "shifting" behavior for more than 3 items hides labels on unselected tabs, which isn't always the desired look.

A top TabBar, for switching views within one screen

DefaultTabController(
  length: 3,
  child: Scaffold(
    appBar: AppBar(
      bottom: const TabBar(
        tabs: [
          Tab(text: 'All'),
          Tab(text: 'Active'),
          Tab(text: 'Completed'),
        ],
      ),
    ),
    body: const TabBarView(
      children: [
        AllTasksList(),
        ActiveTasksList(),
        CompletedTasksList(),
      ],
    ),
  ),
)

DefaultTabController is what links the TabBar (the tab labels) and TabBarView (the actual swipeable content) together — its length must match the number of tabs and views exactly, or Flutter throws an assertion error at runtime.

Controlling the TabBar programmatically with a TabController

class _MyScreenState extends State with SingleTickerProviderStateMixin {
  late TabController _tabController;

  @override
  void initState() {
    super.initState();
    _tabController = TabController(length: 3, vsync: this);
  }

  @override
  void dispose() {
    _tabController.dispose();
    super.dispose();
  }
}

Managing a TabController explicitly (rather than relying on DefaultTabController) is needed when the current tab index must be read or changed programmatically from outside the tab widgets themselves — SingleTickerProviderStateMixin is required because the controller drives the tab-switch animation.

Styling the TabBar's indicator and colors

TabBar(
  indicatorColor: Colors.deepPurple,
  labelColor: Colors.deepPurple,
  unselectedLabelColor: Colors.grey,
  tabs: const [Tab(text: 'All'), Tab(text: 'Active'), Tab(text: 'Completed')],
)

Choosing between the two for a given app

Bottom navigation suits the app's main top-level sections that a user should be able to jump between at any time — a top TabBar suits sub-views of one specific screen's content (like filtering one list by status), and the two are commonly combined, with each bottom-navigation screen optionally having its own internal top tabs.