A controlled, responsive toggle-tab widget for Flutter. It supports text, icons, counter widgets, equal or adaptive widths, gradients, custom text styles, margins, shadows, and animated selection.
Every usage section below includes a GIF captured from its reproducible Maestro flow. The demos show the current controlled-selection API, animated indicator, adaptive widths, custom counter, icons, selected margin, and programmatic selection.
- Flutter 3.47 or newer
- Dart 3.13 or newer
material_ui1.3 or newer
Version 2.0 uses Flutter's standalone Material package. Applications using
legacy package:flutter/material.dart imports should complete the Flutter 3.47
Material migration before upgrading this package.
Add the package and the standalone Material package:
flutter pub add flutter_toggle_tab material_uiThen import both packages where they are used:
import 'package:flutter_toggle_tab/flutter_toggle_tab.dart';
import 'package:material_ui/material_ui.dart';FlutterToggleTab is controlled by selectedIndex. A ValueNotifier keeps
selection updates local to the toggle instead of rebuilding the complete page.
class CategoryToggle extends StatefulWidget {
const CategoryToggle({super.key});
@override
State<CategoryToggle> createState() => _CategoryToggleState();
}
class _CategoryToggleState extends State<CategoryToggle> {
final selectedIndex = ValueNotifier<int>(0);
final tabs = [
DataTab(title: 'Popular'),
DataTab(title: 'Recent'),
DataTab(title: 'Saved'),
];
@override
void dispose() {
selectedIndex.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<int>(
valueListenable: selectedIndex,
builder: (context, currentIndex, _) => FlutterToggleTab(
width: 90,
height: 50,
borderRadius: 30,
dataTabs: tabs,
selectedIndex: currentIndex,
selectedBackgroundColors: const [Colors.blue, Colors.blueAccent],
selectedTextStyle: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w700,
),
unSelectedTextStyle: const TextStyle(
color: Colors.black87,
fontSize: 14,
fontWeight: FontWeight.w500,
),
selectedLabelIndex: (index) => selectedIndex.value = index,
animationDuration: const Duration(milliseconds: 400),
animationCurve: Curves.easeInOutCubic,
isScroll: false,
),
);
}
}The remaining examples assume selectedIndex is a ValueNotifier<int> owned and
disposed by the surrounding State, as demonstrated in the basic example.
Only the matching ValueListenableBuilder subtree rebuilds when its value
changes.
Any widget can be placed after a tab title with counterWidget.
final tabs = [
DataTab(
title: 'Inbox',
counterWidget: const Badge(label: Text('3')),
),
DataTab(title: 'Archived'),
];
ValueListenableBuilder<int>(
valueListenable: selectedIndex,
builder: (context, currentIndex, _) => FlutterToggleTab(
dataTabs: tabs,
selectedIndex: currentIndex,
selectedLabelIndex: (index) => selectedIndex.value = index,
),
);final tabs = [
DataTab(title: 'Male', icon: Icons.person),
DataTab(title: 'Female', icon: Icons.pregnant_woman),
];
ValueListenableBuilder<int>(
valueListenable: selectedIndex,
builder: (context, currentIndex, _) => FlutterToggleTab(
width: 50,
borderRadius: 15,
dataTabs: tabs,
selectedIndex: currentIndex,
selectedLabelIndex: (index) => selectedIndex.value = index,
),
);final tabs = [
DataTab(icon: Icons.person),
DataTab(icon: Icons.pregnant_woman),
];
ValueListenableBuilder<int>(
valueListenable: selectedIndex,
builder: (context, currentIndex, _) => FlutterToggleTab(
width: 40,
borderRadius: 15,
dataTabs: tabs,
iconSize: 40,
selectedIndex: currentIndex,
marginSelected: const EdgeInsets.all(4),
selectedLabelIndex: (index) => selectedIndex.value = index,
),
);Because selection is controlled, change selectedIndex from any event:
ValueListenableBuilder<int>(
valueListenable: selectedIndex,
builder: (context, currentIndex, _) => Column(
children: [
FlutterToggleTab(
dataTabs: tabs,
selectedIndex: currentIndex,
selectedLabelIndex: (index) => selectedIndex.value = index,
),
TextButton(
onPressed: () => selectedIndex.value = 2,
child: const Text('Select the third tab'),
),
],
),
);The selected background slides to the new tab whenever selectedIndex
changes. The animation is enabled by default and can be customized:
ValueListenableBuilder<int>(
valueListenable: selectedIndex,
builder: (context, currentIndex, _) => FlutterToggleTab(
dataTabs: tabs,
selectedIndex: currentIndex,
selectedLabelIndex: (index) => selectedIndex.value = index,
animationDuration: const Duration(milliseconds: 400),
animationCurve: Curves.easeOutBack,
),
);Use Duration.zero when the selection should change without motion.
The indicator calculation follows the selected width mode.
In the default equal-width mode, Flutter's horizontal Alignment coordinate
runs from -1 at the left edge, through 0 at the center, to 1 at the right
edge. Because every tab has the same width, the selected index can be mapped
directly onto that range:
final indicatorAlignment = Alignment(
-1 + (2 * indicatorIndex / (dataTabs.length - 1)),
0,
);The calculation first converts indicatorIndex to a value from 0 to 1,
then scales it to 0 through 2, and finally subtracts 1 to produce the
required -1 through 1 alignment. For three tabs this gives:
| Tab index | Calculation | Horizontal alignment |
|---|---|---|
0 |
-1 + (2 * 0 / 2) |
-1 (left) |
1 |
-1 + (2 * 1 / 2) |
0 (center) |
2 |
-1 + (2 * 2 / 2) |
1 (right) |
At least two tabs are required, so dataTabs.length - 1 cannot be zero.
Adaptive-width mode measures every rendered tab because indexes are no longer
evenly spaced. For selected tab i, its geometry is:
indicatorLeft = width[0] + width[1] + ... + width[i - 1]
indicatorWidth = width[i]
If that position needs to be expressed as an Alignment.x value, account for
the indicator width because Align moves within the remaining free space:
adaptiveAlignmentX = -1 + (2 * indicatorLeft
/ (totalWidth - indicatorWidth))
The implementation uses AnimatedPositioned with indicatorLeft and
indicatorWidth directly. This avoids rounding the measured geometry and lets
the indicator animate its position and width simultaneously. An out-of-range
selectedIndex uses the first geometry while the indicator is hidden.
Enable isAdaptiveWidth when each tab should use the width required by its
content. For example, the second tab below is wider than the first one:
ValueListenableBuilder<int>(
valueListenable: selectedIndex,
builder: (context, currentIndex, _) => FlutterToggleTab(
dataTabs: [
DataTab(title: 'AAAA'),
DataTab(title: 'AAAAAAAAAAAAAAA'),
],
selectedIndex: currentIndex,
selectedLabelIndex: (index) => selectedIndex.value = index,
isAdaptiveWidth: true,
adaptiveTabPadding: const EdgeInsets.symmetric(horizontal: 20),
adaptiveTabAlignment: AlignmentDirectional.centerStart,
),
);The indicator animates both its position and width. The unselected background
also follows the combined adaptive tab width instead of filling unused space.
When that background is wider than the control, the complete tab surface and
indicator scroll horizontally if isScroll is enabled.
The complete adaptive tab surface is centered by default when its combined
width is smaller than the control. Set adaptiveTabAlignment to another
AlignmentGeometry, such as AlignmentDirectional.centerStart or
AlignmentDirectional.centerEnd, to place it at either logical edge. The
alignment has no visible effect while the content is wider than the viewport
and scrolling is required.
To replay the interactions used by the README demos on a running example app:
maestro test .maestroThe six flow files are named after their README sections:
readme_basic.yaml, readme_adaptive.yaml, readme_counter.yaml,
readme_text_icon.yaml, readme_icon_only.yaml, and
readme_programmatic.yaml.
The complete runnable examples are available in
example/lib/main.dart.
Every usage shown above has a corresponding widget test in
example/test/widget_test.dart.
| Property | Type | Default | Description |
|---|---|---|---|
dataTabs |
List<DataTab> |
required | Tabs in display order. At least two are required. |
selectedIndex |
int |
required | Zero-based selected index. An out-of-range value leaves every tab unselected. |
selectedLabelIndex |
ValueChanged<int> |
required | Called when a tab is pressed. |
width |
double? |
100 |
Percentage of screen width, constrained by the parent. |
height |
double? |
45 |
Control height in logical pixels. |
iconSize |
double? |
icon theme | Icon size in logical pixels. |
borderRadius |
double? |
30 |
Radius of the control and selected tab. |
selectedBackgroundColors |
List<Color>? |
theme primary | Selected-tab gradient colors. |
unSelectedBackgroundColors |
List<Color>? |
light grey | Control background gradient colors. |
selectedTextStyle |
TextStyle? |
bodyMedium |
Selected-tab text style. |
unSelectedTextStyle |
TextStyle? |
faded bodyMedium |
Unselected-tab text style. |
begin |
Alignment? |
topCenter |
Gradient start alignment. |
end |
Alignment? |
bottomCenter |
Gradient end alignment. |
isScroll |
bool |
true |
Enables bouncing horizontal scroll physics. |
marginSelected |
EdgeInsets? |
EdgeInsets.zero |
Insets applied to the selected tab. |
isShadowEnable |
bool |
true |
Shows the outer control shadow. |
isInnerShadowEnable |
bool |
true |
Shows the selected-tab shadow. |
animationDuration |
Duration |
250ms |
Duration of the sliding selected-tab indicator. |
animationCurve |
Curve |
easeInOutCubic |
Curve used by the selected-tab indicator. |
isAdaptiveWidth |
bool |
false |
Sizes every tab from its content instead of dividing the available width. |
adaptiveTabPadding |
EdgeInsetsGeometry |
horizontal 16 |
Padding around every tab in adaptive-width mode. |
adaptiveTabAlignment |
AlignmentGeometry |
Alignment.center |
Positions the complete adaptive tab surface when it is narrower than the control. |
| Property | Type | Description |
|---|---|---|
title |
String? |
Text displayed in the tab. |
icon |
IconData? |
Icon displayed before the title. |
counterWidget |
Widget? |
Custom widget displayed after the title. |
isSelected |
bool |
Legacy compatibility value. Selection is controlled by selectedIndex. |
Gradient lists may be empty or contain one color. Empty lists use the default colors, while a single color is duplicated automatically.
Version 2.0 contains intentional breaking changes:
- Upgrade to Flutter 3.47 or newer and Dart 3.13 or newer.
- Migrate Material imports to
package:material_ui/material_ui.dart. - Keep selection state in the parent and pass it through
selectedIndex. - Update
selectedIndexwhenselectedLabelIndexreports a pressed tab. - Do not rely on
DataTab.isSelected; the widget no longer mutates caller data.
Flutter Toggle Tab is available under the MIT License.



