Fliin
On this page

Components / navigation

Tab bar

Switches between the main sections of an app from the bottom of the screen: three to five tabs with icon, label and badge, and a selection that springs from tab to tab.

16 of 16 variants pass
Tab bar, selected state
Platform
Brightness
Text scale
Direction

Import

In a Flutter app where fliin init has run:

fliin add tab-barAlso installs: core

Examples

Selected

The bottom navigation: four tabs, the current one in the accent. onSelected gives the touched index; the screen keeps it.

Tab bar, selected state
import 'package:your_app/ui/fliin/components/tab_bar/fl_tab_bar.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:your_app/ui/fliin/icons/fl_icons.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<TabBarSelectedExample> createState() => _TabBarSelectedExampleState();
}

class _TabBarSelectedExampleState extends State<TabBarSelectedExample> {
  var _index = 0;

  @override
  Widget build(BuildContext context) {
    return FlBottomTabBar(
      index: _index,
      onSelected: (index) => setState(() => _index = index),
      tabs: [
        FlBottomTab(icon: FlIcon(FlIcons.house), label: 'Home'),
        FlBottomTab(
          icon: FlIcon(FlIcons.comment),
          label: 'Messages',
        ),
        FlBottomTab(icon: FlIcon(FlIcons.bell), label: 'Alerts'),
        FlBottomTab(icon: FlIcon(FlIcons.person), label: 'Profile'),
      ],
    );
  }
}

Badge

A tab can carry a badge at the corner of its icon, here the unread count, read out by the screen reader through its own label.

Tab bar, badge state
import 'package:your_app/ui/fliin/components/tab_bar/fl_tab_bar.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:your_app/ui/fliin/icons/fl_icons.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<TabBarBadgeExample> createState() => _TabBarBadgeExampleState();
}

class _TabBarBadgeExampleState extends State<TabBarBadgeExample> {
  var _index = 1;

  @override
  Widget build(BuildContext context) {
    return FlBottomTabBar(
      index: _index,
      onSelected: (index) => setState(() => _index = index),
      tabs: [
        FlBottomTab(icon: FlIcon(FlIcons.house), label: 'Home'),
        FlBottomTab(
          icon: FlIcon(FlIcons.comment),
          label: 'Messages',
          badge: const _Count('3 unread', '3'),
        ),
        FlBottomTab(icon: FlIcon(FlIcons.bell), label: 'Alerts'),
        FlBottomTab(icon: FlIcon(FlIcons.person), label: 'Profile'),
      ],
    );
  }
}

/// A count at the corner of a tab icon, named by [label].
class _Count extends StatelessWidget {
  const _Count(this.label, this.count);

  final String label;
  final String count;

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    return Semantics(
      label: label,
      excludeSemantics: true,
      child: Container(
        constraints: BoxConstraints(minWidth: theme.space.lg),
        padding: EdgeInsetsDirectional.symmetric(horizontal: theme.space.xs),
        decoration: ShapeDecoration(color: theme.colors.dangerFill, shape: const StadiumBorder()),
        child: Text(
          count,
          textAlign: TextAlign.center,
          textScaler: TextScaler.noScaling,
          style: theme.typography
              .resolve(FlTextRole.caption)
              .copyWith(color: theme.colors.onDanger, fontWeight: theme.tokens.typography.weight.semibold),
        ),
      ),
    );
  }
}

Usage

The code the reference images render:

final example = ColoredBox(
  color: FlScope.of(context).colors.surface,
  child: Column(
    crossAxisAlignment: CrossAxisAlignment.stretch,
    spacing: FlScope.of(context).space.xl,
    children: [
      for (final (count, index) in [(3, 0), (5, 4)])
        FlBottomTabBar(
          index: index,
          onSelected: (_) {},
          tabs: [
            for (final (fr, ar) in [
              ('Home', 'الرئيسية'),
              ('Messages', 'الرسائل'),
              ('Alerts', 'التنبيهات'),
              ('Profile', 'الملف'),
              ('Settings', 'الإعدادات'),
            ].take(count))
              FlBottomTab(
                label: fr,
                badge: fr == 'Messages'
                    ? Semantics(
                        label: 'Unread',
                        child: SizedBox.square(
                          dimension: 10,
                          child: DecoratedBox(
                            decoration: ShapeDecoration(
                              color: FlScope.of(context).colors.dangerFill,
                              shape: const CircleBorder(),
                            ),
                          ),
                        ),
                      )
                    : null,
                icon: Builder(
                  builder: (context) => SizedBox.square(
                    dimension: 18,
                    child: DecoratedBox(
                      decoration: ShapeDecoration(
                        color: IconTheme.of(context).color,
                        shape: const CircleBorder(),
                      ),
                    ),
                  ),
                ),
              ),
          ],
        ),
    ],
  ),
);

API

FlBottomTab: final class
FlBottomTab.new: const new({required Widget icon, required String label, Widget? badge})
FlBottomTab.icon: get Widget
FlBottomTab.label: get String
FlBottomTab.badge: get Widget?
FlBottomTabBar: class extends StatefulWidget
FlBottomTabBar.new: const new({super.key, required List<FlBottomTab> tabs, required int index, required ValueChanged<int> onSelected})
FlBottomTabBar.tabs: get List<FlBottomTab>
FlBottomTabBar.index: get int
FlBottomTabBar.onSelected: get ValueChanged<int>
FlBottomTabBar.createState: State<FlBottomTabBar>()

Reference renders

The 16 images published with version 0.2.3, rendered by the shared harness on macOS with Flutter 3.47.7. fliin verify tab-bar compares your copy to them.

iOS · Light
Tab bar, iOS, Light, text 1.0×, Left to right
Text 1.0× · Left to right
Tab bar, iOS, Light, text 1.0×, Right to left
Text 1.0× · Right to left
Tab bar, iOS, Light, text 2.0×, Left to right
Text 2.0× · Left to right
Tab bar, iOS, Light, text 2.0×, Right to left
Text 2.0× · Right to left
iOS · Dark
Tab bar, iOS, Dark, text 1.0×, Left to right
Text 1.0× · Left to right
Tab bar, iOS, Dark, text 1.0×, Right to left
Text 1.0× · Right to left
Tab bar, iOS, Dark, text 2.0×, Left to right
Text 2.0× · Left to right
Tab bar, iOS, Dark, text 2.0×, Right to left
Text 2.0× · Right to left
Android · Light
Tab bar, Android, Light, text 1.0×, Left to right
Text 1.0× · Left to right
Tab bar, Android, Light, text 1.0×, Right to left
Text 1.0× · Right to left
Tab bar, Android, Light, text 2.0×, Left to right
Text 2.0× · Left to right
Tab bar, Android, Light, text 2.0×, Right to left
Text 2.0× · Right to left
Android · Dark
Tab bar, Android, Dark, text 1.0×, Left to right
Text 1.0× · Left to right
Tab bar, Android, Dark, text 1.0×, Right to left
Text 1.0× · Right to left
Tab bar, Android, Dark, text 2.0×, Left to right
Text 2.0× · Left to right
Tab bar, Android, Dark, text 2.0×, Right to left
Text 2.0× · Right to left

Use it to

  • switch between the main sections of the app
  • add icon tabs with labels for the main sections
  • show unread messages on a tab
  • switch from home to profile in one tap
  • keep the main sections within thumb reach
Not for
  • views of the same screen: use tabs (tabs)
  • a screen's actions: use the app bar (app-bar)
  • navigation tucked into a side panel: use a drawer (drawer)
States
  • Selected
  • Badge

Source

The files fliin add tab-bar copies into your app, before your prefix and imports are applied.

tab_bar/fl_tab_bar.dart

// The sliding pill is modified from the Tabs indicator of HeroUI Native 1.0.10,
// Copyright 2025 NextUI Inc., licensed under Apache 2.0:
// https://www.apache.org/licenses/LICENSE-2.0
import 'package:flutter/semantics.dart' show SemanticsRole;
import 'package:flutter/services.dart' show LogicalKeyboardKey;
import 'package:flutter/widgets.dart';
import 'package:fliin_ui/core/fliin.dart';

/// A tab of an [FlBottomTabBar].
final class FlBottomTab {
  /// The [label] tab, drawn by [icon], with a [badge].
  const FlBottomTab({required this.icon, required this.label, this.badge});

  /// The icon, which reads its color and size from the `IconTheme`.
  final Widget icon;

  /// The label under the icon, read by the screen reader.
  final String label;

  /// A badge at the icon's corner (a count, a dot); its semantics are added
  /// to the tab's label.
  final Widget? badge;
}

/// The side of the tab icons, Izydesk's `.tab .i`.
const _iconSize = 22.0;

/// The pill of the selected tab, around the icon: Izydesk's `.tab-pill`.
const _pillWidth = 56.0;
const _pillHeight = 30.0;

/// An app's bottom navigation: three to five [tabs] side by side, the icon
/// above its label, above the bottom safe area.
///
/// The chosen tab [index] carries a `controlPressed` pill under its icon,
/// drawn in `selected`, and its `onSurface` label; the others are muted.
/// The pill slides to the chosen tab
/// with the `segment` spring of the tabs' indicator; under reduced motion,
/// it fades in in place.
///
/// The tabs follow one another by keyboard (tab and arrows) and activate
/// with Enter or Space; the screen reader reads a tab bar with exactly one
/// selected. The bar does not change tabs by itself: [onSelected] receives
/// the tapped index, even the one already selected, which the app can use
/// to scroll its list back to the top.
class FlBottomTabBar extends StatefulWidget {
  /// A bar of [tabs], with tab [index] selected.
  const FlBottomTabBar({super.key, required this.tabs, required this.index, required this.onSelected})
    : assert(tabs.length >= 3 && tabs.length <= 5, 'three to five tabs'),
      assert(index >= 0 && index < tabs.length);

  /// The tabs, in reading order.
  final List<FlBottomTab> tabs;

  /// The selected tab.
  final int index;

  /// Called with the index of the tapped tab.
  final ValueChanged<int> onSelected;

  @override
  State<FlBottomTabBar> createState() => _FlBottomTabBarState();
}

class _FlBottomTabBarState extends State<FlBottomTabBar> with TickerProviderStateMixin {
  late final AnimationController _position = AnimationController.unbounded(vsync: this, value: widget.index.toDouble());
  late final AnimationController _fade = AnimationController(vsync: this, value: 1);

  @override
  void didUpdateWidget(FlBottomTabBar oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.index == widget.index) return;
    final motion = FlMotion.of(context);
    if (motion.reduced) {
      _position.value = widget.index.toDouble();
      _fade.value = 0;
      _fade.driveTo(1, FlFadeMotion(motion.tokens.short3));
    } else {
      _position.driveTo(widget.index.toDouble(), FlSpringMotion(motion.tokens.segment));
    }
  }

  @override
  void dispose() {
    _position.dispose();
    _fade.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    final colors = theme.colors;
    final rtl = Directionality.of(context) == TextDirection.rtl;
    return DecoratedBox(
      decoration: BoxDecoration(
        color: colors.surfaceRaised,
        border: Border(
          top: BorderSide(color: colors.separator, width: theme.size.border),
        ),
      ),
      child: Padding(
        padding: EdgeInsetsDirectional.only(
          top: theme.size.border,
          bottom: MediaQuery.paddingOf(context).bottom,
          start: theme.space.sm,
          end: theme.space.sm,
        ),
        child: SafeArea(
          top: false,
          bottom: false,
          child: FocusTraversalGroup(
            child: Shortcuts(
              shortcuts: const {
                SingleActivator(LogicalKeyboardKey.arrowLeft): DirectionalFocusIntent(TraversalDirection.left),
                SingleActivator(LogicalKeyboardKey.arrowRight): DirectionalFocusIntent(TraversalDirection.right),
              },
              child: Actions(
                actions: {DirectionalFocusIntent: DirectionalFocusAction()},
                child: CustomPaint(
                  painter: _PillPainter(
                    position: _position,
                    fade: _fade,
                    count: widget.tabs.length,
                    rtl: rtl,
                    top: theme.space.sm,
                    radius: theme.shape.radiusCard,
                    color: colors.controlPressed,
                  ),
                  child: Semantics(
                    container: true,
                    role: SemanticsRole.tabBar,
                    explicitChildNodes: true,
                    child: Row(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        for (final (i, tab) in widget.tabs.indexed)
                          Expanded(
                            child: _Tab(
                              tab: tab,
                              selected: i == widget.index,
                              nearness: _position,
                              index: i,
                              onPressed: () => widget.onSelected(i),
                            ),
                          ),
                      ],
                    ),
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

class _Tab extends StatelessWidget {
  const _Tab({
    required this.tab,
    required this.selected,
    required this.nearness,
    required this.index,
    required this.onPressed,
  });

  final FlBottomTab tab;
  final bool selected;

  /// The pill's position, from which the tab derives its color.
  final Animation<double> nearness;
  final int index;
  final VoidCallback onPressed;

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    final colors = theme.colors;
    const role = FlTextRole.caption;
    final badge = tab.badge;
    return FlPressable(
      onPressed: onPressed,
      role: SemanticsRole.tab,
      borderRadius: BorderRadius.circular(theme.shape.radiusCard),
      scaleOnPress: false,
      overlay: FlPressOverlay.none,
      focusRing: FlFocusRingPlacement.inside,
      builder: (context, states) {
        // Hovered or pressed, a tab not chosen shows the pill in
        // `surfaceSecondary`, under the sliding one.
        final touched = !selected && (states.contains(WidgetState.hovered) || states.contains(WidgetState.pressed));
        return Semantics(
          selected: selected,
          child: AnimatedBuilder(
            animation: nearness,
            builder: (context, _) {
              final t = (1 - (nearness.value - index).abs()).clamp(0.0, 1.0);
              return Padding(
                padding: EdgeInsets.symmetric(vertical: theme.space.sm),
                // The label comes first in the tree, so that the screen reader
                // reads it before the badge; it is displayed below.
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  verticalDirection: VerticalDirection.up,
                  spacing: theme.space.xs / 2,
                  children: [
                    Text(
                      tab.label,
                      maxLines: 1,
                      overflow: TextOverflow.ellipsis,
                      softWrap: false,
                      textAlign: TextAlign.center,
                      textScaler: MediaQuery.textScalerOf(context).clamp(maxScaleFactor: theme.size.iconMaxScale),
                      style: theme.typography
                          .resolve(role)
                          .copyWith(
                            color: Color.lerp(colors.onSurfaceMuted, colors.onSurface, t),
                            // Medium, not Izydesk's regular: at 12, regular
                            // Arabic labels fail the measured contrast.
                            fontWeight: theme.tokens.typography.weight.medium,
                          ),
                      strutStyle: theme.typography.strut(
                        role,
                        inheritedFamily: DefaultTextStyle.of(context).style.fontFamily,
                      ),
                    ),
                    Container(
                      height: _pillHeight,
                      width: _pillWidth,
                      decoration: touched
                          ? ShapeDecoration(
                              color: colors.surfaceSecondary,
                              shape: FlContinuousBorder(radius: _pillRadius(theme.shape.radiusCard)),
                            )
                          : null,
                      child: Center(
                        child: Stack(
                          clipBehavior: Clip.none,
                          children: [
                            IconTheme.merge(
                              data: IconThemeData(
                                size: _iconSize,
                                // The chosen tab's icon in `selected`, the
                                // selection color; its label stays in
                                // `onSurface`, which text contrast needs.
                                color: Color.lerp(colors.onSurfaceMuted, colors.selected, t),
                              ),
                              child: SizedBox.square(
                                dimension: _iconSize,
                                child: Center(child: tab.icon),
                              ),
                            ),
                            if (badge != null)
                              PositionedDirectional(
                                top: -theme.space.xs,
                                start: _iconSize - theme.space.sm,
                                child: badge,
                              ),
                          ],
                        ),
                      ),
                    ),
                  ],
                ),
              );
            },
          ),
        );
      },
    );
  }
}

/// The pill's corners: `shape.radiusCard`, a capsule at most.
double _pillRadius(double radius) => radius < _pillHeight / 2 ? radius : _pillHeight / 2;

/// The pill under the icon of the tab that [position] designates, halfway
/// between two tabs during the spring.
class _PillPainter extends CustomPainter {
  _PillPainter({
    required this.position,
    required this.fade,
    required this.count,
    required this.rtl,
    required this.top,
    required this.radius,
    required this.color,
  }) : super(repaint: Listenable.merge([position, fade]));

  final Animation<double> position;
  final Animation<double> fade;
  final int count;
  final bool rtl;
  final double top;
  final double radius;
  final Color color;

  @override
  void paint(Canvas canvas, Size size) {
    final slot = size.width / count;
    final along = (position.value + 0.5) * slot;
    final center = Offset(rtl ? size.width - along : along, top + _pillHeight / 2);
    final rect = Rect.fromCenter(center: center, width: _pillWidth, height: _pillHeight);
    canvas.drawPath(
      FlContinuousBorder.pathFor(rect, _pillRadius(radius)),
      Paint()..color = color.withValues(alpha: color.a * fade.value.clamp(0.0, 1.0)),
    );
  }

  @override
  bool shouldRepaint(_PillPainter oldDelegate) =>
      count != oldDelegate.count ||
      rtl != oldDelegate.rtl ||
      top != oldDelegate.top ||
      radius != oldDelegate.radius ||
      color != oldDelegate.color;
}

Arrows to move, Enter to open, Escape to close.