g1455

Tab bar

GlassTabBar is the floating iOS 26 tab bar. The selected tab lifts into a glass drop you can drag from tab to tab, and the bar can collapse to that tab on a scroll down.

GlassTabBar is the floating tab bar of iOS 26: a Bar holding two to five tabs, each an icon over a label. The selected tab sits on a grey pill in the activeColor. Press it and the pill lifts into a clear glass drop that magnifies the bar under it; drag the drop along the bar and the tab under your finger lights up; let go and that tab is selected. A plain tap on a tab works too.

The layout adapts to the width: below 80 px per tab, the icon sits over the label (phones); above that, they sit side by side (tablets and desktop).

When to use

  • The top-level sections of an app: Home, Search, Library, Profile.
  • Not for switching views inside one screen: that is a segmented control.
  • Not for actions: tabs select a place, they don't do something. Use a toolbar for actions.
  • Not for more than five sections. It asserts at least two.

Usage

const List<GlassTabItem> tabs = <GlassTabItem>[
  GlassTabItem(icon: Icons.home, label: 'Home'),
  GlassTabItem(icon: Icons.search, label: 'Search'),
  GlassTabItem(icon: Icons.person, label: 'Profile'),
];

Positioned(
  left: 16,
  right: 16,
  bottom: MediaQuery.paddingOf(context).bottom + 12,
  child: GlassTabBar(
    items: tabs,
    selectedIndex: _tab,
    onSelected: (int i) => setState(() => _tab = i),
  ),
)

The bar takes its width from its parent, so it needs a bounded width: Positioned(left:, right:) is the usual way. Leave room at the bottom of your scrolling content so the last item is not hidden behind it.

Behaviour

  • The drop magnifies by dropZoom (default kGlassTabDropZoom, 1.17, as on iOS). 1 means no magnification.
  • The drop is glass over glass (the bar), so while it is held the host captures one extra level. At rest, the bar is one surface.
  • Each tab is a button for screen readers, labelled with its label and marked selected.
  • Labels stay at the default text size, as UIKit's do. From an accessibility text size (1.5 times and up), a touch on a tab shows its icon and label large in a panel in the middle of the screen, which follows the finger and goes when it lifts: iOS's large content viewer. It needs an Overlay above the bar, which an app has.
  • The held drop stretches as it sets off and squashes as it lands. dropMotion: tunes it or turns it off; see Drop motion.

Collapsing on scroll

iOS 26's tab bar shrinks to its selected tab while the content scrolls down, and comes back when it scrolls up. With minimizeBehavior: GlassTabBarMinimizeBehavior.onScrollDown, the bar collapses to a circle of its own height at its start edge, the selected tab's icon in it, once the content has gone kGlassTabMinimizeScroll (12 px) down, and expands once it has come as far back up or reached its top. A tap on the circle expands it and selects nothing.

A scroll reports only upward, and the bar is not inside the scroll view, so both go under one GlassTabBarMinimizer. GlassScaffold is one already; a screen built by hand puts one above its body and its bar:

GlassTabBarMinimizer(
  child: Stack(
    children: <Widget>[
      ListView.builder(itemCount: 50, itemBuilder: buildRow),
      Positioned(
        left: 16,
        right: 16,
        bottom: 24,
        child: GlassTabBar(
          items: tabs,
          selectedIndex: _tab,
          onSelected: (int i) => setState(() => _tab = i),
          minimizeBehavior: GlassTabBarMinimizeBehavior.onScrollDown,
        ),
      ),
    ],
  ),
)

By default only the nearest vertical scroll view counts, not one nested in it, nor a horizontal one. notificationPredicate chooses, as an AppBar's does: a list in a PageView, a TabBarView or a NestedScrollView's body is one deeper, and (ScrollNotification n) => n.depth == 1 lets it collapse the bar. The direction is the one on the screen: a reverse: true list collapses the bar when its content moves up, as any list does, and expands at its top edge, which is its far end. GlassTabBarMinimizer.maybeOf(context) is the ValueNotifier<bool> it holds: set it false to expand the bar when the app changes the page.

  • Cost. The circle is the bar shrunk inside the travel region the bar already grows in, so the collapse is no capture: no record over 60 frames of it, against 60 without the declaration. The bar's box keeps its height, so the body is not laid out again. The scroll that set it off captures on every frame anyway, because the content under the bar moves.
  • Collapsed, a screen reader hears one button named for the selected tab. Under reduced motion it is not animated.
  • The circle's size and the spring are layout taste: Apple's collapse was not measured.

An accessory

bottomAccessory is UIKit's tabViewBottomAccessory: a widget on a glass capsule kGlassTabAccessoryHeight (48) tall above the bar, a now-playing row or a status, drawn in the bar's label colour. When the bar collapses it moves down beside the circle, no taller than the circle, inside a travel region of its own, so that is no capture either. The bar's box is taller by the accessory and the gap, and stays that height.

Under adaptive glass the bar reads what is under it and picks its branch, and the accessory wears the same one, whatever is under the accessory: a light bar under a dark capsule would read as two controls.

Custom icons and badges

An IconData is drawn by the bar in the right colour: the accent when selected, otherwise the label colour its glass chose. For anything else, an SVG, an image, a badge, give iconBuilder (or labelBuilder), which is handed that colour and size in a GlassTabItemLook:

GlassTabItem(
  label: 'Inbox',
  iconBuilder: (BuildContext context, GlassTabItemLook look) => GlassBadge(
    count: unread,
    child: Icon(Icons.inbox, color: look.color, size: look.iconSize),
  ),
)

label stays what a screen reader says, whatever the builders draw. The items are built once and again only when their colour changes, which is when the drop moves onto them or off them; on a bar that can collapse, the selected item's icon is built once more, for the circle.

Warning

If glass cards scroll under the tab bar, wrap it in GlassAbove, or put a bottom scroll edge under it. Otherwise the bar shows the content but not the cards.

Tip

GlassScaffold places a tab bar as its bottomBar: lifted, at the bottom of the safe area, with the list padded to end above it, and its scrolls collapse a bar that asks to.

Note

A rebuild that changes nothing the bar draws costs nothing: the bar keeps what it built, and a new onSelected is read when a tap lands. Items compare by their icon and label, and their builders by identity, so an iconBuilder written inline as a closure is a new one on every rebuild — and, under a lifted bar, a capture. Keep such builders in a field or a top-level function.

Code

import 'package:flutter/material.dart';
import 'package:g1455/g1455.dart';

/// An app shell: one page per tab, a floating glass tab bar on top that
/// collapses while a page scrolls down.
/// Assumes a GlassHost above the navigator (MaterialApp.builder).
class AppShell extends StatefulWidget {
  const AppShell({super.key});

  @override
  State<AppShell> createState() => _AppShellState();
}

class _AppShellState extends State<AppShell> {
  static const List<GlassTabItem> _tabs = <GlassTabItem>[
    GlassTabItem(icon: Icons.home, label: 'Home'),
    GlassTabItem(icon: Icons.search, label: 'Search'),
    GlassTabItem(icon: Icons.library_music, label: 'Library'),
    GlassTabItem(icon: Icons.person, label: 'Profile'),
  ];

  int _tab = 0;

  @override
  Widget build(BuildContext context) {
    final double bottom = MediaQuery.paddingOf(context).bottom;
    return Scaffold(
      // Above the pages and the bar both: a page's scrolls reach it, and the
      // bar reads it.
      body: GlassTabBarMinimizer(
        child: Stack(
          children: <Widget>[
            Positioned.fill(
              // Keeps every page alive; the bar floats over whichever is shown.
              child: IndexedStack(
                index: _tab,
                children: <Widget>[
                  for (final GlassTabItem tab in _tabs)
                    ListView.builder(
                      // Room at the end so the last row is not under the bar.
                      padding: EdgeInsets.only(bottom: bottom + 96),
                      itemCount: 40,
                      itemBuilder: (BuildContext context, int i) => ListTile(title: Text('${tab.label} ${i + 1}')),
                    ),
                ],
              ),
            ),
            Positioned(
              left: 16,
              right: 16,
              bottom: bottom + 12,
              child: GlassTabBar(
                items: _tabs,
                selectedIndex: _tab,
                onSelected: (int i) => setState(() => _tab = i),
                minimizeBehavior: GlassTabBarMinimizeBehavior.onScrollDown,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

API

Parameter Type Default Description
items List<GlassTabItem> required The tabs. At least 2.
selectedIndex int required The selected tab.
onSelected ValueChanged<int>? required Called with the new tab. Null disables the bar.
activeColor Color? null Icon and label colour of the selected tab, and of the tab under a held drop. Null for iOS's blue in the bar's appearance: 0xFF007AFF light, 0xFF0A84FF dark.
dropZoom double kGlassTabDropZoom How much the held drop magnifies the bar. 1 for none; must be above 0.
dropMotion GlassDropMotion? null How the held drop stretches and squashes as it moves. Null takes the theme's; GlassDropMotion.none keeps it round. See Drop motion.
minimizeBehavior GlassTabBarMinimizeBehavior .never .onScrollDown collapses the bar to its selected tab while the content scrolls down, under a GlassTabBarMinimizer.
bottomAccessory Widget? null A widget on a glass capsule above the bar, which moves beside the collapsed circle.
key Key? null

GlassTabBarMinimizer

Parameter Type Default Description
child Widget required The subtree whose vertical scrolls collapse the bars in it: the scroll view and the bar both.
notificationPredicate ScrollNotificationPredicate defaultScrollNotificationPredicate Which scroll notifications count. The default takes only depth == 0; n.depth == 1 reaches a list in a PageView or TabBarView.

static ValueNotifier<bool>? maybeOf(BuildContext context): whether the bars below are collapsed, or null with no minimizer above. Writable: set it false to expand them.

GlassTabItem

Parameter Type Default Description
label String required The tab's name: the text drawn unless labelBuilder is given, and what a screen reader says always.
icon IconData? null The tab's icon, drawn in the colour the bar resolved. Ignored when iconBuilder is given.
iconBuilder GlassTabItemBuilder? null Builds the icon instead of icon: an SVG, an image, a badge. Sized by you; the bar's own icons are look.iconSize.
labelBuilder GlassTabItemBuilder? null Builds the label instead of the text of label.

An item needs an icon or an iconBuilder; it asserts. GlassTabItemBuilder is Widget Function(BuildContext context, GlassTabItemLook look).

GlassTabItemLook

What the bar resolved for one item, as it draws it.

Field Type Description
index int Which item.
color Color The colour the item is drawn in now: activeColor when highlighted, otherwise the label colour the bar's glass chose.
iconSize double The size the bar draws its own icons at: 26 stacked, 20 side by side.
labelStyle TextStyle The label's style, color included.
selected bool Whether this is selectedIndex.
highlighted bool Whether this item takes the accent: the selected one at rest, the one under the drop while it is held.
inline bool Whether the bar lays icon beside label (a wide bar) rather than icon over label.

Constants

Name Value Description
kGlassTabDropZoom 1.17 The default dropZoom, read off iOS 26.
kGlassTabDropGrow 10.5 How much larger the held drop is than the resting pill, per side, in px.
kGlassTabMinimizeScroll 12 How far the content scrolls one way, in px, before the bar collapses or expands. Layout taste.
kGlassTabAccessoryHeight 48 The accessory's glass height. Layout taste.