fossui
Components

Bottom nav bar

A strip of top-level destinations for the bottom of a phone screen, each a glyph over a short label, with the current one marked and the safe-area inset handled.

The bar is controlled. value is the current destination and onChanged reports the one the user tapped; the widget holds no selection of its own, because the current destination is usually owned by a route or a restored session. It is generic over the destination type, so the value is your own, not an index.

Bottom nav bar
import 'package:fossui/fossui.dart';

FossBottomNavBar<String>(
  value: section,
  onChanged: (v) => setState(() => section = v),
  items: const [
    FossBottomNavItem(
      value: 'home',
      label: 'Home',
      icon: Icon(LucideIcons.house),
    ),
    FossBottomNavItem(
      value: 'search',
      label: 'Search',
      icon: Icon(LucideIcons.search),
    ),
    FossBottomNavItem(
      value: 'you',
      label: 'You',
      icon: Icon(LucideIcons.user),
    ),
  ],
);

Two destinations is the minimum and five is the practical maximum: past that the labels stop fitting on a small phone, and the answer is fewer destinations rather than a scrolling bar. label is required, since it is the name each cell announces.

Badges and disabled destinations

badge takes any widget and pins it beside the glyph, growing outward so it never covers what it annotates. It is decorative, so anything it needs to say belongs in label too. A destination with enabled: false dims and takes no input.

Bottom nav bar with a badge and a disabled destination
FossBottomNavItem(
  value: 'inbox',
  label: 'Inbox, 3 unread',
  icon: Icon(LucideIcons.mail),
  badge: FossBadge(size: FossBadgeSize.sm, label: Text('3')),
);

FossBottomNavItem(
  value: 'team',
  label: 'Team',
  icon: Icon(LucideIcons.users),
  enabled: false,
);

A filled glyph while current

selectedIcon replaces icon while that destination is current, which is how a filled variant of an outline glyph is expressed. Leave it null and the same glyph stays, changing only its color.

Bottom nav bar with a filled glyph on the current destination
FossBottomNavItem(
  value: 'library',
  label: 'Library',
  icon: Icon(LucideIcons.heart),
  selectedIcon: Icon(LucideIcons.heartHandshake),
);

Re-tapping the current destination

onChanged fires on every tap, including a tap on the destination that is already current. Apps hang "scroll to top" and "pop to root" off that second tap, so the bar does not swallow it.

FossBottomNavBar<String>(
  value: section,
  items: items,
  onChanged: (v) {
    if (v == section) {
      scrollController.animateTo(0, duration: duration, curve: curve);
      return;
    }
    setState(() => section = v);
  },
);

Placing it on a screen

The bar owns the bottom safe-area inset: it adds the inset below itself and paints its fill through it, so the content keeps its full height on a phone with a home indicator. Place it at the bottom of the screen without a SafeArea around it.

It fills the width it is given and hugs its own height, so it sits as the last child of a Column, in a bottomNavigationBar slot, or at the bottom of a Stack.

Column(
  children: [
    Expanded(child: body),
    FossBottomNavBar<String>(
      value: section,
      onChanged: (v) => setState(() => section = v),
      items: items,
    ),
  ],
);

The bar reports a value and nothing else. Wiring it to a navigator, restoring state, and keeping a stack per destination stay with the app.

Disabled

A null onChanged dims every destination and takes no input, which is the state to show while a screen is loading. The surface and its top rule keep their color, so the bar still reads as a bar.

Inert bottom nav bar
FossBottomNavBar<String>(
  value: section,
  items: items,
  onChanged: loading ? null : (v) => setState(() => section = v),
);

Keyboard and semantics

The strip is a tab bar and each cell a tab that reports whether it is selected. The label is the announced name; the glyph and the badge are decorative and excluded. Arrow keys move between cells and follow the text direction, Home and End reach the ends, and Enter or Space activates the focused cell. Disabled cells are skipped. Each cell fills the bar, so the tap target clears the 48 logical pixel floor at five destinations on a narrow phone.

Sizing

A cell floors at 56 logical pixels and grows when the text scaler does, so a long label at 2.0 pushes the bar taller instead of clipping. The glyph is a touch dimension rather than type, so it stays at 24. Labels are capped at one line and ellipsize.

One-off styling

A FossBottomNavBarStyle tweaks a single bar: the surface, the rule, both label colors, the glyph size, the label style, the floor, and whether the rule is drawn at all. To move every bar, retheme FossColors instead.

FossBottomNavBar<String>(
  value: section,
  onChanged: (v) => setState(() => section = v),
  items: items,
  style: const FossBottomNavBarStyle(
    minHeight: 64,
    showTopBorder: false,
  ),
);

API

FossBottomNavBar

PropTypeDefaultDescription
itemsList<FossBottomNavItem<T>>requiredThe ordered destinations, at least two.
valueTrequiredThe current destination; matches one item's value.
onChangedValueChanged<T>?nullCalled on every tap, including a re-tap; null makes the bar inert.
styleFossBottomNavBarStyle?nullPer-instance overrides on the theme.

FossBottomNavItem

PropTypeDefaultDescription
valueTrequiredThe value this destination reports, unique within the bar.
labelStringrequiredThe text under the glyph, and the announced name.
iconWidget?nullThe glyph, sized and colored by the bar.
selectedIconWidget?nullReplaces icon while this destination is current.
badgeWidget?nullA marker pinned beside the glyph; decorative.
enabledbooltrueWhether the destination accepts focus and taps.

FossBottomNavBarStyle

FieldTypeDefaultDescription
backgroundColorColor?nullFill of the bar, painted through the safe-area inset.
borderColorColor?nullColor of the hairline along the top edge.
selectedColorColor?nullGlyph and label color of the current destination.
unselectedColorColor?nullGlyph and label color of the others.
labelStyleTextStyle?nullText style of every label; color is applied per state.
iconSizedouble?nullGlyph size in logical pixels.
minHeightdouble?nullMinimum cell height, excluding the safe-area inset.
showTopBorderbool?nullWhether the top hairline is drawn.

Live demo

Open the interactive gallery to tap through a live bar and watch the re-tap report.

On this page