Flutter TabBar Tutorial: Custom Tabs, Icons, Images and TabBarView

Flutter’s TabBar and TabBarView work together to build a tabbed interface: TabBar renders the row of tabs, and TabBarView renders the swipeable content that corresponds to whichever tab is selected. Note that this is a different widget from the bottom navigation bar pattern — for that, see our separate guide.

Basic Setup with DefaultTabController

The simplest way to wire a TabBar and TabBarView together is DefaultTabController, which manages which tab is selected without you needing your own controller:

DefaultTabController(
  length: 3,
  child: Scaffold(
    appBar: AppBar(
      bottom: const TabBar(
        tabs: [
          Tab(text: 'Home'),
          Tab(text: 'Search'),
          Tab(text: 'Profile'),
        ],
      ),
    ),
    body: const TabBarView(
      children: [
        HomeTab(),
        SearchTab(),
        ProfileTab(),
      ],
    ),
  ),
)

The length passed to DefaultTabController must match the number of Tab widgets in TabBar and the number of children in TabBarView — a mismatch throws at runtime.

Adding Icons or Images to Tabs

Tab accepts an icon alongside or instead of text:

TabBar(
  tabs: [
    Tab(icon: Icon(Icons.home), text: 'Home'),
    Tab(icon: Icon(Icons.search), text: 'Search'),
    Tab(icon: Icon(Icons.person), text: 'Profile'),
  ],
)

For a fully custom tab — a profile picture, a badge with an unread count — pass any widget as the child of a Tab instead of using its text/icon shortcuts.

Custom Styling

Beyond Material defaults, TabBar exposes properties for the indicator, label colors, and more:

TabBar(
  indicatorColor: Colors.deepPurple,
  indicatorWeight: 3,
  labelColor: Colors.deepPurple,
  unselectedLabelColor: Colors.grey,
  tabs: const [
    Tab(text: 'Home'),
    Tab(text: 'Search'),
    Tab(text: 'Profile'),
  ],
)

When You Need More Control: TabController

If you need to switch tabs programmatically (from a button elsewhere, or in response to an event), manage a TabController directly instead of relying on DefaultTabController:

class TabbedScreen extends StatefulWidget {
  const TabbedScreen({super.key});
  @override
  State<TabbedScreen> createState() => _TabbedScreenState();
}

class _TabbedScreenState extends State<TabbedScreen> with SingleTickerProviderStateMixin {
  late final TabController _controller = TabController(length: 3, vsync: this);

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

  void goToProfile() => _controller.animateTo(2);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(bottom: TabBar(controller: _controller, tabs: const [
        Tab(text: 'Home'), Tab(text: 'Search'), Tab(text: 'Profile'),
      ])),
      body: TabBarView(controller: _controller, children: const [
        HomeTab(), SearchTab(), ProfileTab(),
      ]),
    );
  }
}

Note the with SingleTickerProviderStateMixin and manual dispose() call — a TabController you create yourself needs a vsync and must be disposed, unlike DefaultTabController which handles that for you.