Skip to content
Blog

TabBar and TabBarView in Flutter: Building Tabbed Interfaces

Choose DefaultTabController or a manual TabController, keep TabBar and TabBarView in sync, style indicators, and avoid the classic length and disposal pitfalls.

Published on • October 16, 2026

AI Assistant

A Flutter tabbed screen is three widgets and one piece of shared state: TabBar renders the strip, TabBarView renders the pages, and a TabController keeps both pointed at the same index. Everything that goes wrong with tabs in production comes from how that controller is created, how its length relates to two separate lists, and what happens to a page’s state when it scrolls off screen. This post covers all three, plus the styling knobs you will reach for after the first version ships.

DefaultTabController versus a manual TabController

The simplest setup is DefaultTabController, which creates a controller and makes it available to every descendant. The cookbook version in full:

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: DefaultTabController(
        length: 3,
        child: Scaffold(
          appBar: AppBar(
            bottom: const TabBar(
              tabs: [
                Tab(icon: Icon(Icons.directions_car)),
                Tab(icon: Icon(Icons.directions_transit)),
                Tab(icon: Icon(Icons.directions_bike)),
              ],
            ),
            title: const Text('Tabs Demo'),
          ),
          body: const TabBarView(
            children: [
              Icon(Icons.directions_car),
              Icon(Icons.directions_transit),
              Icon(Icons.directions_bike),
            ],
          ),
        ),
      ),
    );
  }
}

TabBar looks up the nearest DefaultTabController ancestor by default, and TabBarView does the same. That implicit lookup is why both widgets must sit under the same controller: if they find different ones (two DefaultTabControllers in the tree, or a manual controller passed to only one of them), the strip and the pages drift apart.

Create the controller yourself when you need programmatic control: animateTo to jump to a tab from a button or a deep link, index, previousIndex, offset, indexIsChanging, and listener callbacks for reacting to page changes. The API reference’s canonical example shows the full lifecycle:

class MyTabbedPage extends StatefulWidget {
  const MyTabbedPage({super.key});

  @override
  State<MyTabbedPage> createState() => _MyTabbedPageState();
}

class _MyTabbedPageState extends State<MyTabbedPage>
    with SingleTickerProviderStateMixin {
  static const List<Tab> myTabs = <Tab>[
    Tab(text: 'LEFT'),
    Tab(text: 'RIGHT'),
  ];

  late TabController _tabController;

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

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        bottom: TabBar(controller: _tabController, tabs: myTabs),
      ),
      body: TabBarView(
        controller: _tabController,
        children: myTabs.map((tab) {
          final label = tab.text!.toLowerCase();
          return Center(child: Text('This is the $label tab'));
        }).toList(),
      ),
    );
  }
}

Three requirements in that snippet are non-negotiable. The state class needs a TickerProvider (that is what SingleTickerProviderStateMixin supplies), the controller needs length at construction, and dispose() must call _tabController.dispose() before super.dispose(). TabController is a ChangeNotifier; skipping disposal leaks tickers and listeners, and debug builds will complain about a ticker left active.

Keeping the strip and the pages in sync

Both API pages state the same contract: if you do not pass a controller, a DefaultTabController ancestor must exist, and TabController.length must equal the length of TabBar.tabs and the length of TabBarView.children. Order matters too; the cookbook calls it out explicitly: the children must correspond to the tabs in the same order.

DefaultTabController(
  length: tabs.length,          // one source of truth
  child: Scaffold(
    appBar: AppBar(bottom: TabBar(tabs: tabs)),
    body: TabBarView(children: pages),  // tabs.length == pages.length
  ),
)

Derive length from one list and build both lists from the same data so they cannot disagree:

final tabs = sections.map((s) => Tab(text: s.title)).toList();
final pages = sections.map((s) => SectionView(section: s)).toList();

If the sections are loaded asynchronously, do not render the TabBar until the data exists. Mounting a controller with length: 0, then changing length later, is where the classic assertion failures come from.

The two classic pitfalls

  • Controller not disposed. Only an issue with a manually created TabController; DefaultTabController owns and cleans up its own. If you hand-roll it, you own dispose().
  • Mismatched lengths. length: 3 with two tabs or two pages throws an assertion at build time. Deriving everything from one list removes the whole class of bug.

One more constraint worth knowing: TabBar requires a Material ancestor. Placing it in AppBar.bottom satisfies that for free; pulling it into a custom header means wrapping the area in a Material widget yourself.

Scrollable tabs

By default tabs share the available width equally. Flip isScrollable: true and the tabs size to their content and scroll horizontally instead, which is what you want for long or variable labels:

TabBar(
  isScrollable: true,
  padding: const EdgeInsets.symmetric(horizontal: 16),
  tabAlignment: TabAlignment.start,
  tabs: const [
    Tab(text: 'Overview'),
    Tab(text: 'Implementation details'),
    Tab(text: 'Migration notes'),
  ],
)

Related knobs: tabAlignment positions the tabs when they do not fill the width, padding insets the strip, physics and scrollController (a TabBarScrollController) control the scroll view’s behavior, and textScaler affects label measurement when users run large fonts. The widget reports its height through preferredSize, which changes depending on whether tabs combine icons and text.

Styling labels, icons, and indicators

Everything visual on a TabBar is a constructor argument, with TabBarThemeData as the fallback when a property is not set in context:

TabBar(
  indicatorColor: Colors.deepPurple,
  indicatorWeight: 3,
  indicatorSize: TabBarIndicatorSize.tab,
  indicatorPadding: const EdgeInsets.symmetric(horizontal: 12),
  dividerColor: Colors.black12,
  dividerHeight: 1,
  labelColor: Colors.deepPurple,
  labelStyle: const TextStyle(fontWeight: FontWeight.w600),
  labelPadding: const EdgeInsets.symmetric(vertical: 12),
  unselectedLabelColor: Colors.grey,
  unselectedLabelStyle: const TextStyle(fontWeight: FontWeight.normal),
  onTap: (index) => debugPrint('tab $index'),
  tabs: const [
    Tab(icon: Icon(Icons.home), text: 'Home'),
    Tab(icon: Icon(Icons.search), text: 'Search'),
  ],
)

Notes that save time:

  • automaticIndicatorColorAdjustment flips the indicator color when it would otherwise be invisible against the bar’s background; leave it on.
  • indicator takes any Decoration, so a pill, a box, or a dot replaces the default line. indicatorSize chooses whether the decoration spans the whole tab or just the label.
  • overlayColor and splashFactory style the ink response, and mouseCursor / onHover / onFocusChange cover desktop input.
  • Material 3 defines TabBar.secondary for second-level tabs; nested primary and secondary tab bars are an officially documented sample, with the primary using DefaultTabController and the secondary using its own manual TabController.
  • For a fully custom indicator animation, indicatorAnimation is the hook.

Tab widgets take icons, text, or both, and can wrap arbitrary widgets, so badges and avatars are just children.

What TabBarView adds

TabBarView is a page view keyed to the controller: children is one widget per tab, plus physics, dragStartBehavior, clipBehavior, and viewportFraction (handy for previewing a sliver of the neighboring page). Give it the same controller you gave the TabBar when you are managing it manually.

Page state when you switch tabs

Here is the part people discover the hard way: when a tab’s page is switched out, its widget is destroyed and its state is lost. PageStorage exists for exactly this case. As its documentation describes, adding a PageStorage at the root plus a PageStorageKey on each page means some of that state, such as a Scrollable’s scroll position, is written to the nearest ancestor PageStorage and restored when the page comes back.

Routes already include a PageStorage, so inside a Navigator route you often get this for free. The requirement is uniqueness: if more than one scrollable appears in the same storage scope, each needs its own PageStorageKey (or keepScrollOffset: false) so positions do not overwrite each other.

TabBarView(
  children: [
    ListView(
      key: const PageStorageKey('inbox-list'),
      controller: _inboxScroll,
      children: const [...],
    ),
    const ProfileTab(),
  ],
)

Scroll offsets are only “some of the state”. Anything else, like a half-typed form field in a tab, needs either a PageStorageKey-bearing widget or a controller held above the TabBarView.

Nested navigation inside tabs

Two patterns work well. For sub-sections, nest another TabBar/TabBarView pair with its own controller; the length contract applies again, so keep the second pair self-contained. For stack navigation inside a tab, host a Navigator in the tab’s subtree (or drive it from your router with per-tab branches) so pushing a detail screen does not disturb the other tabs’ state. Either way, keep the tab’s controller ownership clear: the outer controller belongs to the screen, inner controllers belong to their own state objects and get disposed there.

Common pitfalls, in one list

  • TabController.length differs from tabs.length or children.length.
  • A manual controller never disposed in State.dispose.
  • Two controllers (or two DefaultTabControllers) between the TabBar and the TabBarView, so they index different things.
  • Tab lists built independently from page lists, then drifting after an edit.
  • TabBar outside a Material ancestor, producing a “No Material widget found” error.
  • Expecting off-screen tab content to keep its state without a PageStorageKey.
  • Changing tab count at runtime without rebuilding the controller with a matching length.

Key Takeaways

  • DefaultTabController is the default choice; take a manual TabController only when you need animateTo, index, listeners, or explicit lifecycle control, and dispose it.
  • One contract governs everything: controller length must equal both the tabs list and the TabBarView.children list, in matching order.
  • Derive tabs and pages from a single data list and avoid rendering the controller until the data exists.
  • Use isScrollable plus tabAlignment and padding for long labels; style with indicator*, label*, unselectedLabel*, and theme fallbacks.
  • Off-screen tab pages are destroyed; preserve scroll and other state with PageStorageKey and unique keys per scrollable.
  • TabBar needs a Material ancestor; nested tab bars need their own controller; never let two controllers split the strip and the pages.

References: