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.