Fliin
On this page

Components / actions

Button group

Joins related actions into one bar of attached buttons, such as copy, paste and cut: rounded only at both ends, hairlines between the buttons.

16 of 16 variants pass
Button group, variants state
Platform
Brightness
Text scale
Direction

Import

In a Flutter app where fliin init has run:

fliin add button-groupAlso installs: core, button

Examples

Variants

Buttons joined into one control, in the button variants: primary, tertiary, outline, ghost and danger.

Button group, variants state
import 'package:your_app/ui/fliin/components/button/fl_button.dart';
import 'package:your_app/ui/fliin/components/button_group/fl_button_group.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

class ButtonGroupVariantsExample extends StatelessWidget {
  const ButtonGroupVariantsExample({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      spacing: FlScope.of(context).space.md,
      children: [
        for (final variant in [
          FlButtonVariant.primary,
          FlButtonVariant.tertiary,
          FlButtonVariant.outline,
          FlButtonVariant.ghost,
          FlButtonVariant.danger,
        ])
          FlButtonGroup(
            variant: variant,
            items: [
              FlButtonGroupItem(label: 'Copy', onPressed: () {}),
              FlButtonGroupItem(label: 'Paste', onPressed: () {}),
              FlButtonGroupItem(label: 'Cut', onPressed: () {}),
            ],
          ),
      ],
    );
  }
}

Icons

FlButtonGroupItem.icon for icon-only items, named by label, leading for an icon before the text and trailing after it. semanticLabel names the whole group.

Button group, icons state
import 'package:your_app/ui/fliin/components/button/fl_button.dart';
import 'package:your_app/ui/fliin/components/button_group/fl_button_group.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 ButtonGroupIconsExample extends StatelessWidget {
  const ButtonGroupIconsExample({super.key});

  @override
  Widget build(BuildContext context) {
    final space = FlScope.of(context).space;
    return Wrap(
      alignment: WrapAlignment.center,
      crossAxisAlignment: WrapCrossAlignment.center,
      spacing: space.sm,
      runSpacing: space.sm,
      children: [
        FlButtonGroup(
          semanticLabel: 'Editing',
          items: [
            FlButtonGroupItem.icon(label: 'Edit', icon: FlIcon(FlIcons.pencil), onPressed: () {}),
            FlButtonGroupItem.icon(label: 'Share', icon: FlIcon(FlIcons.arrowUpFromSquare), onPressed: () {}),
            FlButtonGroupItem.icon(label: 'Delete', icon: FlIcon(FlIcons.trashBin), onPressed: () {}),
          ],
        ),
        FlButtonGroup(
          variant: FlButtonVariant.outline,
          items: [
            FlButtonGroupItem(label: 'Previous', leading: FlIcon(FlIcons.chevronLeft), onPressed: () {}),
            FlButtonGroupItem(label: 'Next', trailing: FlIcon(FlIcons.chevronRight), onPressed: () {}),
          ],
        ),
      ],
    );
  }
}

Vertical

axis: Axis.vertical stacks the items, for a short list of actions in a narrow column.

Button group, vertical state
import 'package:your_app/ui/fliin/components/button/fl_button.dart';
import 'package:your_app/ui/fliin/components/button_group/fl_button_group.dart';
import 'package:flutter/widgets.dart';

class ButtonGroupVerticalExample extends StatelessWidget {
  const ButtonGroupVerticalExample({super.key});

  @override
  Widget build(BuildContext context) {
    return Center(
      child: FlButtonGroup(
        axis: Axis.vertical,
        variant: FlButtonVariant.outline,
        items: [
          FlButtonGroupItem(label: 'Copy', onPressed: () {}),
          FlButtonGroupItem(label: 'Paste', onPressed: () {}),
          FlButtonGroupItem(label: 'Cut', onPressed: () {}),
        ],
      ),
    );
  }
}

Full width

expanded: true stretches the group to its parent and shares the width equally between the items.

Button group, full-width state
import 'package:your_app/ui/fliin/components/button_group/fl_button_group.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

class ButtonGroupFullWidthExample extends StatelessWidget {
  const ButtonGroupFullWidthExample({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.stretch,
      spacing: FlScope.of(context).space.md,
      children: [
        FlButtonGroup(
          expanded: true,
          items: [
            FlButtonGroupItem(label: 'Copy', onPressed: () {}),
            FlButtonGroupItem(label: 'Paste', onPressed: () {}),
            FlButtonGroupItem(label: 'Cut', onPressed: () {}),
          ],
        ),
      ],
    );
  }
}

Disabled

enabled: false disables every item of the group at once.

Button group, disabled state
import 'package:your_app/ui/fliin/components/button/fl_button.dart';
import 'package:your_app/ui/fliin/components/button_group/fl_button_group.dart';
import 'package:flutter/widgets.dart';

class ButtonGroupDisabledExample extends StatelessWidget {
  const ButtonGroupDisabledExample({super.key});

  @override
  Widget build(BuildContext context) {
    return Center(
      child: FlButtonGroup(
        enabled: false,
        variant: FlButtonVariant.primary,
        items: [
          FlButtonGroupItem(label: 'Copy', onPressed: () {}),
          FlButtonGroupItem(label: 'Paste', onPressed: () {}),
          FlButtonGroupItem(label: 'Cut', onPressed: () {}),
        ],
      ),
    );
  }
}

Sizes

The three FlButtonSizes, the heights of a lone button: a group lines up with the buttons next to it.

Button group, sizes state
import 'package:your_app/ui/fliin/components/button/fl_button.dart';
import 'package:your_app/ui/fliin/components/button_group/fl_button_group.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

class ButtonGroupSizesExample extends StatelessWidget {
  const ButtonGroupSizesExample({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      spacing: FlScope.of(context).space.md,
      children: [
        for (final size in FlButtonSize.values)
          FlButtonGroup(
            size: size,
            variant: FlButtonVariant.secondary,
            items: [
              FlButtonGroupItem(label: 'First', onPressed: () {}),
              FlButtonGroupItem(label: 'Second', onPressed: () {}),
              FlButtonGroupItem(label: 'Third', onPressed: () {}),
            ],
          ),
      ],
    );
  }
}

Split

A split button: the main action, then a chevron that opens its other options (a menu, a sheet). The same shape joins an action to its quick companion.

Button group, split state
import 'package:your_app/ui/fliin/components/button/fl_button.dart';
import 'package:your_app/ui/fliin/components/button_group/fl_button_group.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 ButtonGroupSplitExample extends StatelessWidget {
  const ButtonGroupSplitExample({super.key});

  @override
  Widget build(BuildContext context) {
    final space = FlScope.of(context).space;
    return Column(
      mainAxisSize: MainAxisSize.min,
      spacing: space.md,
      children: [
        FlButtonGroup(
          variant: FlButtonVariant.primary,
          items: [
            FlButtonGroupItem(label: 'Merge pull request', onPressed: () {}),
            FlButtonGroupItem.icon(label: 'More merge options', icon: FlIcon(FlIcons.chevronDown), onPressed: () {}),
          ],
        ),
        Wrap(
          alignment: WrapAlignment.center,
          spacing: space.sm,
          runSpacing: space.sm,
          children: [
            FlButtonGroup(
              items: [
                FlButtonGroupItem(label: 'Star', leading: FlIcon(FlIcons.star), onPressed: () {}),
                FlButtonGroupItem.icon(label: 'More star options', icon: FlIcon(FlIcons.chevronDown), onPressed: () {}),
              ],
            ),
            FlButtonGroup(
              items: [
                FlButtonGroupItem(label: 'Pinned', leading: FlIcon(FlIcons.mapPin), onPressed: () {}),
                FlButtonGroupItem.icon(label: 'More pin options', icon: FlIcon(FlIcons.chevronDown), onPressed: () {}),
              ],
            ),
          ],
        ),
      ],
    );
  }
}

Toggles

selected makes each item a toggle: one at a time for a choice, any number for options. Your state says which are on.

Button group, toggles state
import 'package:your_app/ui/fliin/components/button_group/fl_button_group.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 ButtonGroupTogglesExample extends StatefulWidget {
  const ButtonGroupTogglesExample({super.key});

  @override
  State<ButtonGroupTogglesExample> createState() => _ButtonGroupTogglesExampleState();
}

class _ButtonGroupTogglesExampleState extends State<ButtonGroupTogglesExample> {
  var _range = 'Week';
  final _alerts = {'Likes'};

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      spacing: FlScope.of(context).space.md,
      children: [
        FlButtonGroup(
          semanticLabel: 'Range',
          items: [
            for (final range in ['Day', 'Week', 'Month'])
              FlButtonGroupItem(
                label: range,
                selected: _range == range,
                onPressed: () => setState(() => _range = range),
              ),
          ],
        ),
        FlButtonGroup(
          semanticLabel: 'Alerts',
          items: [
            for (final (name, icon) in [
              ('Likes', FlIcons.heart),
              ('Saves', FlIcons.bookmark),
              ('Replies', FlIcons.comment),
            ])
              FlButtonGroupItem.icon(
                label: name,
                icon: FlIcon(icon),
                selected: _alerts.contains(name),
                onPressed: () => setState(() => _alerts.contains(name) ? _alerts.remove(name) : _alerts.add(name)),
              ),
          ],
        ),
      ],
    );
  }
}

Without separator

separated: false drops the hairlines: the shared fill alone joins the buttons.

Button group, without-separator state
import 'package:your_app/ui/fliin/components/button_group/fl_button_group.dart';
import 'package:flutter/widgets.dart';

class ButtonGroupWithoutSeparatorExample extends StatelessWidget {
  const ButtonGroupWithoutSeparatorExample({super.key});

  @override
  Widget build(BuildContext context) {
    return Center(
      child: FlButtonGroup(
        separated: false,
        items: [
          FlButtonGroupItem(label: 'First', onPressed: () {}),
          FlButtonGroupItem(label: 'Second', onPressed: () {}),
          FlButtonGroupItem(label: 'Third', onPressed: () {}),
        ],
      ),
    );
  }
}

Usage

The code the reference images render:

final example = ColoredBox(
  color: FlScope.of(context).colors.surface,
  child: SingleChildScrollView(
    padding: EdgeInsetsDirectional.all(FlScope.of(context).space.lg),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      spacing: FlScope.of(context).space.sm,
      children: [
        Wrap(
          spacing: FlScope.of(context).space.sm,
          runSpacing: FlScope.of(context).space.sm,
          children: [
            for (final look in [
              FlButtonVariant.primary,
              FlButtonVariant.tertiary,
              FlButtonVariant.outline,
              FlButtonVariant.ghost,
              FlButtonVariant.danger,
            ])
              FlButtonGroup(
                variant: look,
                items: [
                  FlButtonGroupItem(label: 'Copy', onPressed: () {}),
                  FlButtonGroupItem(label: 'Paste', onPressed: () {}),
                  FlButtonGroupItem(label: 'Cut', onPressed: () {}),
                ],
              ),
            FlButtonGroup(
              semanticLabel: 'Edit',
              items: [
                FlButtonGroupItem.icon(
                  label: 'Edit',
                  icon: FlIcon(FlIcons.pencil),
                  onPressed: () {},
                ),
                FlButtonGroupItem.icon(
                  label: 'Share',
                  icon: FlIcon(FlIcons.arrowUpFromSquare),
                  onPressed: () {},
                ),
                FlButtonGroupItem.icon(
                  label: 'Delete',
                  icon: FlIcon(FlIcons.trashBin),
                  onPressed: null,
                ),
              ],
            ),
            FlButtonGroup(
              axis: Axis.vertical,
              variant: FlButtonVariant.outline,
              items: [
                FlButtonGroupItem(
                  label: 'Previous',
                  leading: FlIcon(FlIcons.chevronLeft),
                  onPressed: () {},
                ),
                FlButtonGroupItem(
                  label: 'Next',
                  trailing: FlIcon(FlIcons.chevronRight),
                  onPressed: () {},
                ),
              ],
            ),
          ],
        ),
        FlButtonGroup(
          expanded: true,
          items: [
            FlButtonGroupItem(label: 'Day', selected: false, onPressed: () {}),
            FlButtonGroupItem(label: 'Week', selected: true, onPressed: () {}),
            FlButtonGroupItem(
              label: 'Month',
              selected: false,
              onPressed: () {},
            ),
          ],
        ),
        Align(
          alignment: AlignmentDirectional.centerStart,
          child: FlButtonGroup(
            enabled: false,
            variant: FlButtonVariant.primary,
            items: [
              FlButtonGroupItem(label: 'Copy', onPressed: () {}),
              FlButtonGroupItem(label: 'Paste', onPressed: () {}),
              FlButtonGroupItem(label: 'Cut', onPressed: () {}),
            ],
          ),
        ),
      ],
    ),
  ),
);

API

FlButtonGroupItem: final class
FlButtonGroupItem.new: const new({required String label, required VoidCallback? onPressed, Widget? leading, Widget? trailing, bool? selected})
FlButtonGroupItem.icon: get Widget?
FlButtonGroupItem.label: get String
FlButtonGroupItem.leading: get Widget?
FlButtonGroupItem.trailing: get Widget?
FlButtonGroupItem.selected: get bool?
FlButtonGroupItem.onPressed: get VoidCallback?
FlButtonGroup: class extends StatelessWidget
FlButtonGroup.new: const new({super.key, required List<FlButtonGroupItem> items, FlButtonVariant variant, FlButtonSize? size, Axis axis, bool expanded, bool separated, bool enabled, String? semanticLabel})
FlButtonGroup.items: get List<FlButtonGroupItem>
FlButtonGroup.variant: get FlButtonVariant
FlButtonGroup.size: get FlButtonSize?
FlButtonGroup.axis: get Axis
FlButtonGroup.expanded: get bool
FlButtonGroup.separated: get bool
FlButtonGroup.enabled: get bool
FlButtonGroup.semanticLabel: get String?
FlButtonGroup.build: Widget(BuildContext context)

Reference renders

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

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

Use it to

  • keep copy, paste and cut joined in one strip
  • offer edit, share and delete side by side on a document
  • keep back and forward actions attached side by side
  • make a split row of related actions
  • keep attached buttons inside one shared shape
  • present related actions stacked in one block
Not for
  • a single choice among several options: use the segmented control (segmented)
  • a toolbar with keyboard navigation: use the toolbar (toolbar)
  • a single action: use a button (button)
States
  • Variants
  • Icons
  • Vertical
  • Full width
  • Disabled

Source

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

button_group/fl_button_group.dart

// Modified from the ButtonGroup of HeroUI React v3, Copyright 2025 NextUI Inc.,
// licensed under Apache 2.0: https://www.apache.org/licenses/LICENSE-2.0
import 'package:flutter/widgets.dart';
import 'package:fliin_ui/components/button/fl_button.dart';
import 'package:fliin_ui/core/fliin.dart';

/// A button of an [FlButtonGroup]: its [label], optional [leading] and
/// [trailing] icons, or an [icon] alone, and what it does. With [selected],
/// it is a toggle of the group: bold, italic,
/// underline.
final class FlButtonGroupItem {
  /// A labeled button.
  const FlButtonGroupItem({required this.label, required this.onPressed, this.leading, this.trailing, this.selected})
    : icon = null;

  /// A square button that shows only [icon]; [label] remains what the screen
  /// reader reads.
  const FlButtonGroupItem.icon({required this.label, required Widget this.icon, required this.onPressed, this.selected})
    : leading = null,
      trailing = null;

  /// The text shown, and read by the screen reader.
  final String label;

  /// An icon before the text, in the text's tint.
  final Widget? leading;

  /// An icon after the text, in the text's tint: a chevron that opens a
  /// menu, a next arrow.
  final Widget? trailing;

  /// The icon of an icon-only button.
  final Widget? icon;

  /// On or off, for a toggle; null for an action. On, the button takes the
  /// `selected` fill and `onSelected` tint, like [FlButton]'s selected
  /// state, and the screen reader reads it as selected; the app keeps the
  /// state (one item on for an exclusive choice, any number for options).
  final bool? selected;

  /// Called when the button is pressed; null disables this button alone, at
  /// `size.disabledOpacity`.
  final VoidCallback? onPressed;
}

typedef _Paint = ({Color? fill, Color? border, Color foreground, Color highlight, Color? hover});

/// Related actions joined into one control: two to
/// five [items] side by side, or stacked when [axis] is vertical, sharing
/// one [variant] fill and one outer shape; only the ends of the row are
/// rounded, by the theme's button shape. A hairline at half the height, in
/// the text color at 15 %, separates two buttons unless [separated] is
/// false. The buttons do not shrink when pressed: their fill
/// steps to the hovered state, then the press veil appears.
///
/// The same heights as [FlButton], so a group lines up with a lone button.
/// Items with `selected` make it a group of toggles: text styles, filters.
/// For one exclusive choice among views, `segmented` slides its thumb.
///
/// ```dart
/// FlButtonGroup(items: [
///   FlButtonGroupItem(label: 'Merge', onPressed: merge),
///   FlButtonGroupItem.icon(label: 'More', icon: FlIcon(FlIcons.chevronDown), onPressed: openMenu),
/// ])
/// ```
class FlButtonGroup extends StatelessWidget {
  /// A group of [items], `tertiary` by default.
  const FlButtonGroup({
    super.key,
    required this.items,
    this.variant = FlButtonVariant.tertiary,
    this.size,
    this.axis = Axis.horizontal,
    this.expanded = false,
    this.separated = true,
    this.enabled = true,
    this.semanticLabel,
  }) : assert(items.length >= 2 && items.length <= 5, 'two to five buttons');

  /// The buttons, in reading order.
  final List<FlButtonGroupItem> items;

  /// The fill and the text color of the whole group, those of [FlButton].
  final FlButtonVariant variant;

  /// The height, the padding and the text size; null follows the theme's
  /// density.
  final FlButtonSize? size;

  /// A row, or a column whose buttons share the widest one's width.
  final Axis axis;

  /// True: the group takes the full width, its buttons share it equally
  /// A row only.
  final bool expanded;

  /// False: no hairline between the buttons.
  final bool separated;

  /// False: the whole group is faded, with neither touch nor focus.
  final bool enabled;

  /// The group's name for the screen reader ("Text alignment"); null, the
  /// buttons are read one after another.
  final String? semanticLabel;

  /// The colors of [variant], as in [FlButton]; [_Paint.hover] is the fill a
  /// button takes under the pointer, null when it keeps the group's.
  _Paint _paint(FlTheme theme) {
    final c = theme.colors;
    final k = theme.size.hoverBrightness;
    Color darker(Color fill) => Color.from(alpha: fill.a, red: fill.r * k, green: fill.g * k, blue: fill.b * k);
    final subtle = c.controlPressed.withValues(alpha: theme.size.pressedOverlayOpacity);
    return switch (variant) {
      FlButtonVariant.primary => (
        fill: c.accent,
        border: null,
        foreground: c.onAccent,
        highlight: c.accentPressed,
        hover: darker(c.accent),
      ),
      FlButtonVariant.secondary => (
        fill: c.control,
        border: null,
        foreground: c.onAccentSoft,
        highlight: c.controlPressed,
        hover: c.controlPressed,
      ),
      FlButtonVariant.tertiary => (
        fill: c.control,
        border: null,
        foreground: c.onControl,
        highlight: c.controlPressed,
        hover: c.controlPressed,
      ),
      FlButtonVariant.outline => (
        fill: null,
        border: c.border,
        foreground: c.onControl,
        highlight: subtle,
        hover: c.controlPressed,
      ),
      FlButtonVariant.ghost => (
        fill: null,
        border: null,
        foreground: c.onControl,
        highlight: subtle,
        hover: c.controlPressed,
      ),
      FlButtonVariant.danger => (
        fill: c.dangerFill,
        border: null,
        foreground: c.onDanger,
        highlight: c.dangerPressed,
        hover: darker(c.dangerFill),
      ),
      FlButtonVariant.dangerSoft => (
        fill: c.dangerSoft,
        border: null,
        foreground: c.onDangerSoft,
        highlight: c.dangerSoftPressed,
        hover: null,
      ),
      FlButtonVariant.inverse => (
        fill: c.selected,
        border: null,
        foreground: c.onSelected,
        highlight: Color.alphaBlend(c.onSelected.withValues(alpha: theme.size.pressedOverlayOpacity), c.selected),
        hover: darker(c.selected),
      ),
    };
  }

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    final s = theme.size;
    final metrics = switch (size ?? FlButtonSize.of(theme.density)) {
      FlButtonSize.sm => (
        height: s.controlCompact,
        padding: s.buttonPaddingSm,
        gap: s.buttonGapSm,
        icon: s.buttonIconSm,
        text: FlTextRole.controlSm,
        radius: theme.shape.radiusButton,
      ),
      FlButtonSize.md => (
        height: s.controlComfortable,
        padding: s.buttonPaddingMd,
        gap: s.buttonGapMd,
        icon: s.buttonIconMd,
        text: FlTextRole.controlMd,
        radius: theme.shape.radiusButton,
      ),
      FlButtonSize.lg => (
        height: s.controlSpacious,
        padding: s.buttonPaddingLg,
        gap: s.buttonGapLg,
        icon: s.buttonIconLg,
        text: FlTextRole.controlLg,
        radius: theme.shape.radiusButtonLarge,
      ),
    };
    final paint = _paint(theme);
    final radius = switch (theme.shape.button) {
      FlButtonShape.square => 0.0,
      FlButtonShape.rounded => metrics.radius,
      // Half a button's height: a stadium in a row, the same round ends on
      // a column.
      FlButtonShape.pill => metrics.height / 2,
    };
    final border = paint.border;
    final inset = border == null ? 0.0 : s.border;
    final inner = Radius.circular((radius - inset).clamp(0, double.infinity));
    final horizontal = axis == Axis.horizontal;
    BorderRadiusDirectional corners(int i) {
      final first = i == 0;
      final last = i == items.length - 1;
      return horizontal
          ? BorderRadiusDirectional.horizontal(start: first ? inner : Radius.zero, end: last ? inner : Radius.zero)
          : BorderRadiusDirectional.vertical(top: first ? inner : Radius.zero, bottom: last ? inner : Radius.zero);
    }

    final iconSize = MediaQuery.textScalerOf(context).clamp(maxScaleFactor: s.iconMaxScale).scale(metrics.icon);
    final iconTheme = IconThemeData(color: paint.foreground, size: iconSize);
    final textStyle = theme.typography.resolve(metrics.text).copyWith(color: paint.foreground);
    final strut = theme.typography.strut(metrics.text, inheritedFamily: DefaultTextStyle.of(context).style.fontFamily);
    final block = (metrics.height - theme.typography.style(metrics.text).lineHeight) / 2;
    final motion = FlMotion.of(context);
    final scaler = MediaQuery.textScalerOf(context);

    // A button's width on one line: shares of the row in proportion to it
    // leave every label whole when the row fits, and shrink them alike when
    // it does not.
    double natural(FlButtonGroupItem item) {
      if (item.icon != null) return metrics.height;
      final painter = TextPainter(
        text: TextSpan(text: item.label, style: DefaultTextStyle.of(context).style.merge(textStyle)),
        strutStyle: strut,
        textScaler: scaler,
        textDirection: Directionality.of(context),
        maxLines: 1,
      )..layout();
      final width = painter.width;
      painter.dispose();
      final icons = [item.leading, item.trailing].nonNulls.length;
      return width + 2 * metrics.padding + icons * (iconSize + metrics.gap);
    }

    final c = theme.colors;
    // A selected toggle of a neutral group: the `selected` fill of FlButton's
    // toggles; a filled group keeps its colors and says it by its icon.
    final neutral = switch (variant) {
      FlButtonVariant.secondary || FlButtonVariant.tertiary || FlButtonVariant.outline || FlButtonVariant.ghost => true,
      _ => false,
    };
    bool on(int i) => neutral && items[i].selected == true;
    final onPaint = (
      fill: c.selected,
      border: null,
      foreground: c.onSelected,
      highlight: Color.alphaBlend(c.onSelected.withValues(alpha: s.pressedOverlayOpacity), c.selected),
      hover: c.selected,
    );

    Widget button(int i) {
      final item = items[i];
      final icon = item.icon;
      final leading = item.leading;
      final trailing = item.trailing;
      final selected = on(i);
      final look = selected ? onPaint : paint;
      final tint = selected ? IconThemeData(color: c.onSelected, size: iconSize) : iconTheme;
      final shape = RoundedRectangleBorder(borderRadius: corners(i));
      Widget glyph(Widget child) => FlPixelSnap(
        child: IconTheme.merge(data: tint, child: child),
      );
      final content = icon != null
          ? glyph(icon)
          : Row(
              mainAxisSize: MainAxisSize.min,
              spacing: metrics.gap,
              children: [
                if (leading != null) glyph(leading),
                Flexible(
                  child: Text(
                    item.label,
                    textAlign: TextAlign.center,
                    style: selected ? textStyle.copyWith(color: c.onSelected) : textStyle,
                    strutStyle: strut,
                  ),
                ),
                if (trailing != null) glyph(trailing),
              ],
            );
      return FlPressable(
        onPressed: enabled ? item.onPressed : null,
        label: item.label,
        selected: item.selected,
        borderRadius: corners(i),
        scaleOnPress: false,
        builder: (context, states) {
          final hovered =
              !states.contains(WidgetState.disabled) &&
              (states.contains(WidgetState.hovered) || states.contains(WidgetState.pressed));
          final hover = look.hover;
          return Opacity(
            // A disabled group fades once, as a whole.
            opacity: enabled && states.contains(WidgetState.disabled) ? s.disabledOpacity : 1,
            child: ConstrainedBox(
              constraints: BoxConstraints(minHeight: metrics.height, minWidth: icon == null ? 0 : metrics.height),
              // The hover fill fades in over 100 ms.
              child: TweenAnimationBuilder<Color?>(
                tween: ColorTween(
                  end: selected
                      ? c.selected
                      : hovered && hover != null
                      ? hover
                      : hover?.withValues(alpha: 0),
                ),
                duration: motion.reduced ? Duration.zero : theme.motion.short2,
                curve: theme.motion.easeOut,
                builder: (context, fill, child) => DecoratedBox(
                  decoration: ShapeDecoration(color: fill, shape: shape),
                  child: child,
                ),
                child: Stack(
                  alignment: Alignment.center,
                  children: [
                    Positioned.fill(
                      child: ClipPath(
                        clipper: ShapeBorderClipper(shape: shape, textDirection: Directionality.of(context)),
                        child: FlPressHighlight(color: look.highlight),
                      ),
                    ),
                    Padding(
                      padding: EdgeInsetsDirectional.symmetric(
                        horizontal: icon == null ? metrics.padding : 0,
                        vertical: icon == null ? block : 0,
                      ),
                      child: content,
                    ),
                  ],
                ),
              ),
            ),
          );
        },
      );
    }

    final hairline = paint.foreground.withValues(alpha: paint.foreground.a * 0.15);
    Widget separator() => horizontal
        ? SizedBox(
            width: s.border,
            child: FractionallySizedBox(heightFactor: 0.5, child: ColoredBox(color: hairline)),
          )
        : SizedBox(
            height: s.border,
            child: FractionallySizedBox(widthFactor: 0.5, child: ColoredBox(color: hairline)),
          );

    final children = [
      for (var i = 0; i < items.length; i++) ...[
        // Next to a selected toggle, its fill already marks the edge.
        if (i > 0 && separated)
          if (on(i - 1) || on(i))
            SizedBox(width: horizontal ? s.border : null, height: horizontal ? null : s.border)
          else
            separator(),
        // Loose otherwise: under large text, a label wraps rather than
        // pushing the group past its row.
        if (horizontal)
          (expanded ? Expanded(child: button(i)) : Flexible(flex: natural(items[i]).ceil(), child: button(i)))
        else
          button(i),
      ],
    ];
    final Widget line = horizontal
        ? IntrinsicHeight(
            child: Row(
              mainAxisSize: expanded ? MainAxisSize.max : MainAxisSize.min,
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: children,
            ),
          )
        : IntrinsicWidth(
            child: Column(
              mainAxisSize: MainAxisSize.min,
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: children,
            ),
          );
    final group = DecoratedBox(
      decoration: ShapeDecoration(
        color: paint.fill,
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(radius),
          side: border == null ? BorderSide.none : BorderSide(color: border, width: s.border),
        ),
      ),
      child: Padding(padding: EdgeInsetsDirectional.all(inset), child: line),
    );
    return Semantics(
      container: semanticLabel != null,
      label: semanticLabel,
      explicitChildNodes: true,
      child: Opacity(opacity: enabled ? 1 : s.disabledOpacity, child: group),
    );
  }
}

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