Flutter TabBar Widget in 2026: TabBar, TabBarView, TabController, and the Material 3 Primary vs Secondary Distinction

Build a Flutter TabBar widget to navigate between pages in a single view. Customize indicators, handle controllers, and pair with GetWidget's GFTabs.

flutter tabbar widget hero image

Tabs are one of the oldest UI patterns in mobile and Flutter ships a complete tab system built around three widgets that have to coordinate: TabBar (the row of tab labels), TabBarView (the content panels), and a TabController (the state binding between them). Material 3 added a second tab style (secondary) for nested navigation. Most tutorials cover the basic two-tab demo and skip the parts that actually break in production: state preservation across tab switches, programmatic tab control, the M3 primary vs secondary distinction, and the choice between TabBar and NavigationBar that catches every junior Flutter engineer.

Two framing notes. The flutter tabbar widget is for within-screen tab navigation (think email Primary/Social/Promotions tabs inside the Mail screen). For app-level destinations like Home / Search / Profile, you want NavigationBar (Material 3) or BottomNavigationBar (M2 fallback), not a TabBar. Mixing these is the most common navigation architecture mistake we catch in Flutter code review. The other framing point: every TabBar usage needs a TabController, and the choice between DefaultTabController and an explicit TabController drives whether you can control the tabs programmatically.

Flutter tabbar widget vs NavigationBar: pick by navigation scope, not aesthetic

Both widgets render a row of items the user taps to switch content. The decision tracks one thing: is the user switching content within a single screen, or moving between top-level destinations in the app?

User action TabBar (in-screen tabs)NavigationBar (M3 app-level)BottomNavigationBar (M2 app-level)
Switch between content categories on one screen Yes, default No No
Navigate to a different top-level destination (Home, Search, Profile) No Yes, M3 default OK if pre-M3
Filter or segment a list within a screen Yes, secondary TabBar No No
App with 3-5 primary destinations Wrong widget Yes, M3 recommended Yes
Tab content uses the same AppBar across tabs Yes, TabBar in AppBar.bottom No No

The basic flutter tabbar widget setup: DefaultTabController, TabBar, TabBarView

The simplest setup wraps a Scaffold in a DefaultTabController, puts a TabBar in the AppBar's bottom slot, and a TabBarView in the body. DefaultTabController handles the controller state for you when you do not need programmatic control.

lib/screens/inbox_screen.dart
DART
DefaultTabController(
  length: 3,
  child: Scaffold(
    appBar: AppBar(
      title: const Text('Inbox'),
      bottom: const TabBar(
        tabs: [
          Tab(text: 'Primary'),
          Tab(text: 'Social'),
          Tab(text: 'Promotions'),
        ],
      ),
    ),
    body: const TabBarView(
      children: [
        _PrimaryEmails(),
        _SocialEmails(),
        _PromotionalEmails(),
      ],
    ),
  ),
);

The length on DefaultTabController must match the number of Tab widgets in the TabBar AND the number of children in the TabBarView. Mismatching these throws an exception at runtime, but the error message is not always obvious. We require a quick visual inspection of length and children counts in every TabBar code review.

Material 3 added primary and secondary TabBar variants — knowing the difference

Material 3 introduced two visual variants of TabBar that signal different levels of navigation hierarchy. Primary TabBar (the default when useMaterial3 is true) is for the top-level tabs on a screen, typically inside the AppBar. Secondary TabBar is for nested tabs within a primary-tab content area — for example, switching between subviews inside one tab's content.

lib/screens/nested_tabs.dart
DART
// Primary TabBar (default visual treatment)
TabBar(
  tabs: const [
    Tab(text: 'Active'),
    Tab(text: 'Archived'),
  ],
);

// Secondary TabBar (for nested-tab cases)
TabBar.secondary(
  tabs: const [
    Tab(text: 'All'),
    Tab(text: 'Unread'),
    Tab(text: 'Starred'),
  ],
);

The visual differences are subtle but matter. Primary TabBar gets a thicker indicator and uses ColorScheme.primary for selected state. Secondary TabBar uses a thinner indicator and ColorScheme.secondary. Sticking to the primary variant for every TabBar in the app makes nested tabs feel as important as top-level navigation, which fights against the M3 visual hierarchy.

Explicit TabController: when DefaultTabController is not enough

DefaultTabController is the easy mode. It works as long as nothing outside the TabBar needs to know or change the selected tab. The moment you need to switch tabs programmatically (deep link, action button, onTap of a non-tab widget), respond to tab changes (analytics, state sync), or animate transitions custom, you switch to an explicit TabController in a StatefulWidget.

lib/screens/programmatic_tabs.dart
DART
class InboxScreen extends StatefulWidget {
  @override
  State<InboxScreen> createState() => _InboxScreenState();
}

class _InboxScreenState extends State<InboxScreen> with SingleTickerProviderStateMixin {
  late final TabController _tabController;

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

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

  void _onTabChanged() {
    if (_tabController.indexIsChanging) {
      analytics.trackTabSwitch(_tabController.index);
    }
  }

  void jumpToPromotions() => _tabController.animateTo(2);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        bottom: TabBar(controller: _tabController, tabs: _tabs),
      ),
      body: TabBarView(controller: _tabController, children: _pages),
    );
  }
}

Three rules we enforce on every explicit TabController. First: dispose the controller in the State's dispose method or you leak memory across screen rebuilds. Second: use SingleTickerProviderStateMixin (not TickerProviderStateMixin) when you only need one controller — the single variant is cheaper. Third: check indexIsChanging inside listener callbacks so you do not trigger your handler twice during the animation transition.

Keeping tab state alive across switches: AutomaticKeepAliveClientMixin

The most-reported TabBar bug: user scrolls down in Tab 1, switches to Tab 2 and back, and the scroll position resets to the top. By default, TabBarView rebuilds children when they re-enter view. To preserve state (scroll position, form input, controller state), each child widget needs to mix in AutomaticKeepAliveClientMixin and return true from wantKeepAlive.

lib/widgets/keep_alive_tab_content.dart
DART
class _PrimaryEmails extends StatefulWidget {
  @override
  State<_PrimaryEmails> createState() => _PrimaryEmailsState();
}

class _PrimaryEmailsState extends State<_PrimaryEmails>
    with AutomaticKeepAliveClientMixin {
  @override
  bool get wantKeepAlive => true;

  @override
  Widget build(BuildContext context) {
    super.build(context); // required by the mixin
    return ListView.builder(/* ... */);
  }
}

Three caveats. The keep-alive only works if the child is a StatefulWidget — stateless children cannot keep state by definition. Calling super.build(context) inside build is required by the mixin and the analyzer will not always warn you if you skip it. And keep-alive holds memory: every kept tab stays mounted for the life of the TabBarView, so do not blanket-apply it to tabs containing heavy widget trees (large lists, video players) where the memory cost outweighs the UX win.

Dynamic flutter tabbar widget: adding and removing tabs at runtime

DefaultTabController has a fixed length that cannot change. If your app adds tabs at runtime (chat conversations, dynamically loaded categories, user-pinned filters), use an explicit TabController and rebuild it with a new length whenever the tab list changes. The old controller must be disposed before the new one is created.

lib/screens/dynamic_tabs.dart
DART
void _rebuildController(int newLength) {
  final oldIndex = _tabController.index.clamp(0, newLength - 1);
  _tabController.removeListener(_onTabChanged);
  _tabController.dispose();
  _tabController = TabController(
    length: newLength,
    vsync: this,
    initialIndex: oldIndex,
  );
  _tabController.addListener(_onTabChanged);
  setState(() {});
}

Common bug: not clamping the index when the new length is smaller than the previous index. If the user was on tab 5 and you remove tabs down to 3 total, the new controller initialIndex defaults to 0 unless you carry the previous position. Clamping to the new length keeps the user where they roughly were instead of jumping to the start.

Styling the flutter tabbar widget: indicator, label, padding, and theme defaults

TabBar exposes about a dozen styling props inline (indicatorColor, indicatorWeight, labelColor, unselectedLabelColor, labelStyle, padding, isScrollable). The trap is using these inline on every TabBar instead of setting them once at the theme level. Inline overrides become inconsistencies the moment your designer tweaks the brand.

Set TabBarTheme in your app's ThemeData and every TabBar in the app picks up the same defaults. Override on individual TabBars only when the design specifically calls for a different treatment on that screen. We catch the inline-everywhere anti-pattern on roughly half the Flutter apps we audit, and the fix is always 'move it to TabBarTheme.'

Two M3 styling specifics worth knowing. First, indicatorSize defaults to TabBarIndicatorSize.tab in Material 3 (the indicator spans the full tab width). The older TabBarIndicatorSize.label (indicator only under the text) was the M2 default and now looks dated. Stick with tab unless you have a specific design reason. Second, the new indicatorAnimation prop controls whether the indicator slides between tabs or fades — slide is the M3 default and matches user expectations, but fade can feel right for compact secondary tabs where the sliding motion is too busy.

For scrollable tabs (isScrollable: true), set tabAlignment to TabAlignment.start to pin tabs to the left edge instead of centering them. Material 3 made scrollable TabBars more common for content categories with more than 4-5 tabs, and the default centered alignment looks awkward when only a few tabs are visible at once. Setting it once in TabBarTheme prevents every screen from making this decision separately.

TabBar performance: where production apps slow down

Accessibility: TabBar announces tabs but you still own the labels

TabBar handles screen reader announcement of the selected tab and its position in the set ("Primary, tab 1 of 3"). You own the tab labels — make them descriptive. Icon-only tabs need a tooltip on the Tab widget (or wrap the icon in Semantics with a label). Skipping tooltips on icon-only tabs is one of the most common a11y bugs in Flutter apps.

lib/widgets/accessible_tabs.dart
DART
TabBar(
  tabs: const [
    Tab(icon: Icon(Icons.inbox), text: 'Inbox'), // text doubles as label
    Tab(
      icon: Icon(Icons.send),
      // No text — wrap in Semantics or use tooltip:
      child: Tooltip(message: 'Sent items', child: Icon(Icons.send)),
    ),
  ],
);

TabBar is one widget in the navigation surface set we maintain default patterns for. The full Flutter widgets catalog covers the rest. For broader Flutter production patterns we apply on every delivery — state, performance, CI/CD, accessibility — see our Flutter mobile app development field guide.

One closing pattern catches more Flutter teams than any other TabBar-related mistake. Teams new to Flutter often build the app's primary navigation using TabBar at the root level — a TabBar across the top of every screen with Home, Search, Profile, Settings. This works for the prototype and breaks the moment users expect deep linking, back-button behavior per section, or per-tab navigation stacks. Each top-level destination needs its own Navigator (its own back stack), which TabBar does not provide. The replacement pattern: NavigationBar (or BottomNavigationBar on pre-M3 apps) with a per-tab Navigator under each destination, or a router like GoRouter with a StatefulShellRoute that holds independent state per branch. Migrating from TabBar root navigation to one of these patterns is a multi-day refactor; using the right widget on day one avoids it entirely.

One more concrete number worth surfacing on TabBar performance. We have measured tab-switch latency in production Flutter apps where tabs contain real content: a clean TabBar with const children and no AutomaticKeepAliveClient typically switches in 60–80ms on a mid-range Android device. The same TabBar with three keep-alive tabs holding 200-row lists each switches in 30–40ms but holds 60-100MB more memory across the screen's lifetime. The trade-off is real. We default to no keep-alive and only opt in for tabs where the user demonstrably expects state preservation (forms, scroll position in long lists).

Common questions about the flutter tabbar widget

When should I use TabBar vs NavigationBar in Flutter?

TabBar is for within-screen tabs (switching content categories on one screen). NavigationBar (Material 3) is for app-level destinations like Home, Search, Profile. If users would expect a 'back' button to take them somewhere different per tab, you want NavigationBar at the app level, not TabBar.

What is the difference between DefaultTabController and TabController in Flutter?

DefaultTabController is the easy-mode wrapper that creates and manages a TabController automatically. Use it when nothing outside the TabBar needs to know about the selected tab. TabController (explicit) gives you a reference you can call animateTo on, listen to for tab-change events, and pass to other widgets that need to coordinate with the TabBar.

How do I keep tab state alive when switching tabs in Flutter?

Mix AutomaticKeepAliveClientMixin into the State of each tab's child widget and return true from wantKeepAlive. Remember to call super.build(context) at the top of build. Keep-alive holds memory, so use it selectively — not on every tab in a 10-tab screen.

Can I change the number of tabs at runtime?

Not with DefaultTabController (fixed length). Use an explicit TabController and dispose-and-rebuild it whenever the tab count changes. Carry the user's current index across the rebuild by clamping to the new length so they do not get sent back to the first tab.

What is the difference between primary and secondary TabBar in Material 3?

Primary TabBar (the default) is for top-level tabs on a screen and uses ColorScheme.primary for the selected state with a thicker indicator. Secondary TabBar (TabBar.secondary) is for nested tabs inside primary-tab content and uses ColorScheme.secondary with a thinner indicator. The hierarchy signal is what makes M3 navigation feel coherent.

How do I switch tabs programmatically in Flutter?

Use an explicit TabController and call _tabController.animateTo(index). DefaultTabController exposes the controller via DefaultTabController.of(context) but you typically promote to explicit when you need programmatic control.

Why does my TabBarView crash with a 'controller length mismatch' error?

The TabController's length must equal both the number of Tab widgets in the TabBar and the number of children in the TabBarView. Add or remove a Tab without updating length or children and you get this error at runtime. Always change all three together.

How do I make TabBar accessible to screen readers?

TabBar handles selected-tab announcement automatically (the screen reader reads the tab label and its position). For icon-only tabs, wrap the icon in a Tooltip widget or a Semantics widget with an explicit label. Icon-only tabs without either are the most common Flutter a11y bug for TabBar.

Can I use TabBar without an AppBar?

Yes. TabBar can sit anywhere in your widget tree: inside a Column, inside the body of a Scaffold without an AppBar, inside a CustomScrollView with a SliverPersistentHeader. The AppBar.bottom slot is convenient because it gives you the bottom-border treatment for free, but it is not required. When you place TabBar outside AppBar, wrap it in a Material widget so the splash and ripple effects render with the right elevation context against the surface behind.

MORE IN /FLUTTER APP DEVELOPMENT COMPANY

Continue reading.

Flutter Mobile App Development: A 2026 Production Field Guide — hero image
#flutter#mobile-development

Flutter Mobile App Development: A 2026 Production Field Guide

How we structure Flutter projects at GetWidget in 2026: feature-first layout, Riverpod defaults, Dart 3 records and sealed classes, Material 3 theming, the 200-line widget rule, performance diagnosis, CI/CD pipelines, and the production pitfalls that bite teams after launch.

Navin Sharma Navin Sharma
12m
best flutter drawer widgets hero image
#flutter#drawer widget

Best Flutter Drawer Widgets in 2026: Drawer, NavigationDrawer, and the Packages Still Worth Using

Top 10 Flutter drawer widgets for slide-out side menus: customization patterns, code examples, and how to integrate GetWidget's GFDrawer into your app.

Navin Sharma Navin Sharma
5m
flutter button widget component hero image
#flutter#flutter buttons

How to Design Custom Flutter Buttons in 2026: A Practitioner Guide to All 5 M3 Button Widgets

Flutter button widgets in 2026: the five Material 3 button classes (Filled, FilledTonal, Elevated, Outlined, Text), ButtonStyle deep-dive, GFButton when M3 isn't enough, FAB and IconButton patterns, M2 migration map, plus the accessibility and performance bars no tutorial covers.

Navin Sharma Navin Sharma
7m
Back to Blog