capsule_nav_bar 1.2.0 copy "capsule_nav_bar: ^1.2.0" to clipboard
capsule_nav_bar: ^1.2.0 copied to clipboard

A bottom navigation bar that floats over the content as a rounded capsule, its selection marked by a pill sliding between destinations.

capsule_nav_bar #

A bottom navigation bar that floats over the content as a rounded capsule, its selection marked by a pill that slides between destinations. Each destination carries two icons — a line weight and a filled one — and the bar swaps them as the pill arrives.

Material comes from the material_ui package rather than from package:flutter/material.dart, so your app has to be on material_ui too — its ThemeData and ColorScheme are not the framework's. Beyond that the bar asks nothing of you: no assets, no localisations, no icon pack, no other pub.flutter-io.cn dependency. Colours, shapes and metrics come from a ThemeExtension you register, and with none registered it derives a palette from the ambient ColorScheme.

Tapping through destinations: the pill slides and the icons fill

The bar over page content, in light and dark, with the scrim fading that content out underneath it:

The bar over content, light and dark

And the pill at three selections — the selected destination is drawn in its filled icon, the rest in their line icon:

The pill at three selections

Install #

flutter pub add capsule_nav_bar

Or add it to pubspec.yaml yourself — it is a runtime dependency:

dependencies:
  capsule_nav_bar: ^1.2.0
  material_ui: ^1.1.0

then:

flutter pub get

Use #

The bar is driven by you: it reports the tapped index and you decide what that means.

CapsuleNavBar(
  destinations: const [
    NavBarDestination(
      label: 'Home',
      lineIcon: Icons.home_outlined,
      fillIcon: Icons.home,
    ),
    NavBarDestination(
      label: 'Search',
      lineIcon: Icons.search_outlined,
      fillIcon: Icons.search,
    ),
    NavBarDestination(
      label: 'Account',
      lineIcon: Icons.person_outline,
      fillIcon: Icons.person,
    ),
  ],
  activeIndex: _index,
  onDestinationSelected: (i) => setState(() => _index = i),
)

Icons come from wherever you like — the bar takes plain IconData. Material's outlined and filled variants pair up directly, as does any other family shipping two weights, which gives the filled-on-select look for free; with only one weight to hand, leave fillIcon off and it is used for both.

The bar is built to overlap what it sits above, so put it at the bottom of a Stack over your content rather than in Scaffold.bottomNavigationBar:

Scaffold(
  body: Stack(
    children: [
      content,
      Positioned(
        left: 0,
        right: 0,
        bottom: 0,
        child: CapsuleNavBar(...),
      ),
    ],
  ),
)

It sizes itself to destinations.length * itemWidth, centred in the width it is given, and shrinks its destinations only when that width cannot hold them.

With a router #

onDestinationSelected owns the navigation, so a StatefulNavigationShell drops straight in. The bar highlights the tap before calling you, so it answers the touch even while the branch switch is in flight, and an out-of-range activeIndex — the -1 a router reports between branches — leaves the highlight where it is instead of blinking it off.

CapsuleNavBar(
  destinations: destinations,
  activeIndex: shell.currentIndex,
  // Re-tapping the active destination resets that branch to its root.
  onDestinationSelected: (i) =>
      shell.goBranch(i, initialLocation: i == shell.currentIndex),
)

Theming #

Register CapsuleNavBarTheme as a ThemeExtension and every bar in the app follows your light and dark themes:

MaterialApp(
  theme: ThemeData(
    extensions: [
      CapsuleNavBarTheme(
        barColor: palette.surface.withValues(alpha: 0.8),
        indicatorColor: palette.primaryLight,
        selectedItemColor: palette.primary,
        unselectedItemColor: palette.textMuted,
        labelStyle: typography.captionMedium,
        barShadows: [BoxShadow(color: palette.shadow, blurRadius: 10)],
        scrimColor: palette.surface,
      ),
    ],
  ),
);

Anything on the theme can also be set per instance, and the instance wins:

CapsuleNavBar(
  destinations: destinations,
  activeIndex: _index,
  onDestinationSelected: _onSelected,
  indicatorColor: Colors.amber,
  itemWidth: 64,
  showScrim: false,
)

The knobs, in short:

Colours barColor, barGradient, indicatorColor, selectedItemColor, unselectedItemColor, scrimColor
Type labelStyle, selectedLabelStyle, fontFamily
Shape barRadius, indicatorRadius, barShape, indicatorShape, smoothCorners, barShadows
Glass glass, glassBlur
Metrics height, itemWidth, iconSize, iconLabelSpacing, barPadding, itemPadding, margin, scrimHeight

Corners #

By default the bar's corners are a superellipse — the smoothed, iOS-style squircle — drawn by the framework's RoundedSuperellipseBorder and rasterised by the engine, so it costs no more than a plain rounded rectangle. Set smoothCorners: false for circular arcs.

For corner geometry the framework does not ship, hand over a whole ShapeBorder and keep that dependency in your own pubspec:

CapsuleNavBarTheme(
  // …
  barShape: SmoothRectangleBorder(
    borderRadius: SmoothBorderRadius(cornerRadius: 32, cornerSmoothing: 1),
  ),
);

Glass #

Set glass: true and the bar frosts what passes beneath it — a backdrop blur clipped to the capsule, under a translucent fill:

CapsuleNavBarTheme(
  glass: true,
  glassBlur: 24,
  // The frost only shows through a see-through bar.
  barColor: palette.surface.withValues(alpha: 0.6),
  // …
);

The order the layers go down in is the whole trick, and it is the one the platform chrome uses: shadow, then the clip, then the blur, then the fill and the border on top of it. A fill painted underneath the blur would be smeared along with the content behind and lose its edge; on top, it reads as a translucent sheet laid over a blurred backdrop. Which is why the fill has to be translucent — an opaque barColor hides the frost completely — and why barShadows, painted outside the clip, are never smeared.

For the diagonal sheen a real glass surface catches, give it a gradient instead; barGradient wins over barColor:

CapsuleNavBarTheme(
  glass: true,
  barGradient: LinearGradient(
    begin: AlignmentDirectional.topStart,
    end: AlignmentDirectional.bottomEnd,
    colors: [
      palette.surface.withValues(alpha: 0.72),
      palette.surface.withValues(alpha: 0.42),
    ],
  ),
  // A hairline around the capsule separates it from what it floats over.
  barShape: RoundedSuperellipseBorder(
    borderRadius: BorderRadius.circular(999),
    side: BorderSide(color: palette.border),
  ),
);

A BackdropFilter is not free on low-end devices, which is why this is off by default; glass: false gives you back a plain translucent bar.

The scrim #

Because the bar overlaps the content, it fades a gradient up behind itself to keep scrolling text from running into it. Set scrimColor to your page background to switch it on — usually the same colour as barColor at full opacity — and scrimHeight for how far up it reaches. Pass showScrim: false to drop it for one bar.

The safe area #

The bar keeps clear of the system inset at the bottom of the screen — the home indicator, or a gesture bar — by adding it under margin, so the bar floats the full margin above the inset rather than sitting on top of it. Set useSafeArea: false when the bar is already inside a SafeArea, or when it is not at the bottom of the screen at all.

Accessibility & RTL #

  • Every destination is a Semantics button carrying its selected state, with a semanticLabel per destination when the visible label is too terse to read aloud.
  • Destinations take keyboard focus in the traversal order and answer Enter and Space, and show a hover, focus and pressed state clipped to the pill's own shape.
  • Destinations are 48 dp tall at the default height, meeting the minimum tap target.
  • The bar grows with the platform's text scale so large labels are not ellipsized, up to maxHeightScale (1.6x by default) times its height. Set it to 1 to pin the bar and let long labels ellipsize instead.
  • With "reduce motion" set the indicator snaps to the selection rather than sliding to it.
  • The indicator is positioned with AlignmentDirectional, so it starts from the right and slides leftwards under TextDirection.rtl.

Example #

example/ is a runnable app: the bar over scrolling content, in light and dark, LTR and RTL.

cd example && flutter run

The images above are rendered from the real widgets, so they can be regenerated whenever the bar changes:

cd example && flutter test --update-goldens test/screenshots_test.dart && flutter test tool/record_nav_gif.dart

Contributing #

CI runs on every push and pull request: formatting, analysis, the test suite, the example's own tests, and a pub publish dry run. The same steps run locally:

dart format --output=none --set-exit-if-changed lib test example/lib example/test example/tool && flutter analyze && flutter test

The widget's goldens live in test/goldens/ and are tagged, so they are compared on one platform only — rasterisation differs between them, and a golden rendered elsewhere would fail on pixels nobody changed. Regenerate them after any deliberate visual change:

flutter test --update-goldens --tags golden

Licence #

MIT — see LICENSE.

1
likes
160
points
258
downloads
screenshot

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A bottom navigation bar that floats over the content as a rounded capsule, its selection marked by a pill sliding between destinations.

Homepage
Repository (GitHub)
View/report issues

Topics

#navigation-bar #bottom-navigation #navigation #widget

License

MIT (license)

Dependencies

flutter, material_ui

More

Packages that depend on capsule_nav_bar