capsule_nav_bar 1.2.0
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.

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

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

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
Semanticsbutton carrying its selected state, with asemanticLabelper 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 itsheight. Set it to1to 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 underTextDirection.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.
