Fliin
On this page

Components / forms

Tag group

Lets people pick tags among wrapping capsules, such as interests or filters, and remove them with their cross. Single or multiple selection, two backgrounds, three sizes.

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

Import

In a Flutter app where fliin init has run:

fliin add tag-groupAlso installs: core, icons, field

Examples

Amenities

Tags to pick several options from a short list, under a label: selected holds their values.

Tag group, amenities state
import 'package:your_app/ui/fliin/components/field/fl_field.dart';
import 'package:your_app/ui/fliin/components/tag_group/fl_tag_group.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

class TagGroupAmenitiesExample extends StatefulWidget {
  const TagGroupAmenitiesExample({super.key});

  @override
  State<TagGroupAmenitiesExample> createState() => _TagGroupAmenitiesExampleState();
}

class _TagGroupAmenitiesExampleState extends State<TagGroupAmenitiesExample> {
  var _selected = <String>{'parking', 'pool'};

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      spacing: FlScope.of(context).space.md,
      children: [
        const FlLabel('Amenities'),
        FlTagGroup<String>(
          tags: const [
            FlTag('laundry', 'Laundry'),
            FlTag('fitness', 'Fitness center'),
            FlTag('parking', 'Parking'),
            FlTag('pool', 'Swimming pool'),
            FlTag('breakfast', 'Breakfast'),
          ],
          selection: FlTagSelection.multiple,
          selected: _selected,
          onSelectionChanged: (selected) => setState(() => _selected = selected),
          removeLabel: (label) => 'Remove $label',
        ),
      ],
    );
  }
}

Selected

Selected tags take the accent; touching one toggles it.

Tag group, selected state
import 'package:your_app/ui/fliin/components/tag_group/fl_tag_group.dart';
import 'package:flutter/widgets.dart';

class TagGroupSelectedExample extends StatefulWidget {
  const TagGroupSelectedExample({super.key});

  @override
  State<TagGroupSelectedExample> createState() => _TagGroupSelectedExampleState();
}

class _TagGroupSelectedExampleState extends State<TagGroupSelectedExample> {
  var _selected = <String>{'news', 'travel'};

  @override
  Widget build(BuildContext context) {
    return FlTagGroup<String>(
      tags: const [
        FlTag('news', 'News'),
        FlTag('travel', 'Travel'),
        FlTag('gaming', 'Gaming'),
        FlTag('food', 'Food'),
      ],
      selection: FlTagSelection.multiple,
      selected: _selected,
      onSelectionChanged: (selected) => setState(() => _selected = selected),
      removeLabel: (label) => 'Remove $label',
    );
  }
}

Unselected

Nothing selected yet.

Tag group, unselected state
import 'package:your_app/ui/fliin/components/tag_group/fl_tag_group.dart';
import 'package:flutter/widgets.dart';

class TagGroupUnselectedExample extends StatefulWidget {
  const TagGroupUnselectedExample({super.key});

  @override
  State<TagGroupUnselectedExample> createState() => _TagGroupUnselectedExampleState();
}

class _TagGroupUnselectedExampleState extends State<TagGroupUnselectedExample> {
  var _selected = <String>{};

  @override
  Widget build(BuildContext context) {
    return FlTagGroup<String>(
      tags: const [
        FlTag('news', 'News'),
        FlTag('travel', 'Travel'),
        FlTag('gaming', 'Gaming'),
        FlTag('food', 'Food'),
      ],
      selection: FlTagSelection.multiple,
      selected: _selected,
      onSelectionChanged: (selected) => setState(() => _selected = selected),
      removeLabel: (label) => 'Remove $label',
    );
  }
}

Disabled

disabled locks every tag and keeps the selection visible.

Tag group, disabled state
import 'package:your_app/ui/fliin/components/tag_group/fl_tag_group.dart';
import 'package:flutter/widgets.dart';

class TagGroupDisabledExample extends StatefulWidget {
  const TagGroupDisabledExample({super.key});

  @override
  State<TagGroupDisabledExample> createState() => _TagGroupDisabledExampleState();
}

class _TagGroupDisabledExampleState extends State<TagGroupDisabledExample> {
  var _selected = <String>{'news'};

  @override
  Widget build(BuildContext context) {
    return FlTagGroup<String>(
      tags: const [
        FlTag('news', 'News'),
        FlTag('travel', 'Travel'),
        FlTag('gaming', 'Gaming'),
        FlTag('food', 'Food'),
      ],
      selection: FlTagSelection.multiple,
      selected: _selected,
      disabled: true,
      onSelectionChanged: (selected) => setState(() => _selected = selected),
      removeLabel: (label) => 'Remove $label',
    );
  }
}

Removable

With onRemove, each tag gets a remove button; removeLabel names it for the screen reader.

Tag group, removable state
import 'package:your_app/ui/fliin/components/tag_group/fl_tag_group.dart';
import 'package:flutter/widgets.dart';

class TagGroupRemovableExample extends StatefulWidget {
  const TagGroupRemovableExample({super.key});

  @override
  State<TagGroupRemovableExample> createState() => _TagGroupRemovableExampleState();
}

class _TagGroupRemovableExampleState extends State<TagGroupRemovableExample> {
  var _selected = <String>{};
  var _tags = const [
    FlTag('news', 'News'),
    FlTag('travel', 'Travel'),
    FlTag('gaming', 'Gaming'),
    FlTag('food', 'Food'),
  ];

  @override
  Widget build(BuildContext context) {
    return FlTagGroup<String>(
      tags: _tags,
      selection: FlTagSelection.multiple,
      selected: _selected,
      onSelectionChanged: (selected) => setState(() => _selected = selected),
      onRemove: (value) => setState(() => _tags = [
        for (final tag in _tags)
          if (tag.value != value) tag,
      ]),
      removeLabel: (label) => 'Remove $label',
    );
  }
}

Sizes

Three sizes: sm, md (the default) and lg.

Tag group, sizes state
import 'package:your_app/ui/fliin/components/tag_group/fl_tag_group.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      spacing: FlScope.of(context).space.md,
      children: [
        for (final size in FlTagSize.values)
          FlTagGroup<String>(
            key: ValueKey(size),
            size: size,
            tags: const [FlTag('news', 'News'), FlTag('travel', 'Travel'), FlTag('gaming', 'Gaming')],
          ),
      ],
    );
  }
}

Leading

leading puts an icon or a small avatar before the label, in the tag's color; label and description frame the group.

Tag group, leading state
import 'package:your_app/ui/fliin/components/tag_group/fl_tag_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 TagGroupLeadingExample extends StatefulWidget {
  const TagGroupLeadingExample({super.key});

  @override
  State<TagGroupLeadingExample> createState() => _TagGroupLeadingExampleState();
}

class _TagGroupLeadingExampleState extends State<TagGroupLeadingExample> {
  var _topic = <String>{'travel'};

  @override
  Widget build(BuildContext context) {
    return FlTagGroup<String>(
      label: 'Topics',
      description: 'Pick the one you follow most',
      selection: FlTagSelection.single,
      selected: _topic,
      onSelectionChanged: (value) => setState(() => _topic = value),
      tags: [
        FlTag('news', 'News', leading: FlIcon(FlIcons.bell)),
        FlTag('travel', 'Travel', leading: FlIcon(FlIcons.mapPin)),
        FlTag('shopping', 'Shopping', leading: FlIcon(FlIcons.shoppingCart)),
        FlTag('food', 'Food', leading: FlIcon(FlIcons.heart)),
      ],
    );
  }
}

Error

error shows why the selection is rejected, in place of the description, until the user picks a tag.

Tag group, error state
import 'package:your_app/ui/fliin/components/tag_group/fl_tag_group.dart';
import 'package:flutter/widgets.dart';

class TagGroupErrorExample extends StatefulWidget {
  const TagGroupErrorExample({super.key});

  @override
  State<TagGroupErrorExample> createState() => _TagGroupErrorExampleState();
}

class _TagGroupErrorExampleState extends State<TagGroupErrorExample> {
  var _amenities = <String>{};

  @override
  Widget build(BuildContext context) {
    return FlTagGroup<String>(
      label: 'Amenities',
      description: 'Selected: ${_amenities.length}',
      error: _amenities.isEmpty ? 'Select at least one amenity' : null,
      selection: FlTagSelection.multiple,
      selected: _amenities,
      onSelectionChanged: (value) => setState(() => _amenities = value),
      tags: const [
        FlTag('laundry', 'Laundry'),
        FlTag('fitness', 'Fitness center'),
        FlTag('parking', 'Parking'),
        FlTag('pool', 'Swimming pool'),
        FlTag('breakfast', 'Breakfast'),
      ],
    );
  }
}

Usage

The code the reference images render:

final example = ColoredBox(
  color: FlScope.of(context).colors.surface,
  child: Padding(
    padding: EdgeInsetsDirectional.all(FlScope.of(context).space.lg),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      spacing: FlScope.of(context).space.lg,
      children: [
        FlTagGroup<int>(
          tags: [
            for (final (i, label)
                in (variant.dir == TextDirection.rtl
                        ? const ['أخبار', 'سفر', 'ألعاب', 'طبخ']
                        : const ['News', 'Travel', 'Games', 'Cooking'])
                    .indexed)
              FlTag(i, label, disabled: i == 3),
          ],
          selection: FlTagSelection.multiple,
          selected: const {1, 2},
          onSelectionChanged: (_) {},
        ),
        FlTagGroup<int>(
          tags: [
            for (final (i, label)
                in (variant.dir == TextDirection.rtl
                        ? const ['أخبار', 'سفر']
                        : const ['News', 'Travel'])
                    .indexed)
              FlTag(i, label),
          ],
          variant: FlTagGroupVariant.surface,
          size: FlTagSize.lg,
          onRemove: (_) {},
          removeLabel: (label) => 'Remove $label',
        ),
      ],
    ),
  ),
);

API

FlTagGroupVariant: enum {primary, surface}
FlTagGroupVariant.primary: static final FlTagGroupVariant
FlTagGroupVariant.surface: static final FlTagGroupVariant
FlTagSize: enum {sm, md, lg}
FlTagSize.sm: static final FlTagSize
FlTagSize.md: static final FlTagSize
FlTagSize.lg: static final FlTagSize
FlTagSelection: enum {none, single, multiple}
FlTagSelection.none: static final FlTagSelection
FlTagSelection.single: static final FlTagSelection
FlTagSelection.multiple: static final FlTagSelection
FlTag: final class<T>
FlTag.new: const new(T value, String label, {bool disabled, Widget? leading})
FlTag.value: get T
FlTag.label: get String
FlTag.leading: get Widget?
FlTag.disabled: get bool
FlTagGroup: class<T> extends StatelessWidget
FlTagGroup.new: const new({super.key, required List<FlTag<T>> tags, FlTagSelection selection, Set<T> selected, ValueChanged<Set<T>>? onSelectionChanged, ValueChanged<T>? onRemove, String Function(String label)? removeLabel, FlTagGroupVariant variant, FlTagSize size, bool disabled, Widget? empty, String? label, String? description, String? error})
FlTagGroup.label: get String?
FlTagGroup.description: get String?
FlTagGroup.error: get String?
FlTagGroup.tags: get List<FlTag<T>>
FlTagGroup.selection: get FlTagSelection
FlTagGroup.selected: get Set<T>
FlTagGroup.onSelectionChanged: get ValueChanged<Set<T>>?
FlTagGroup.onRemove: get ValueChanged<T>?
FlTagGroup.removeLabel: get String Function(String label)?
FlTagGroup.variant: get FlTagGroupVariant
FlTagGroup.size: get FlTagSize
FlTagGroup.disabled: get bool
FlTagGroup.empty: get Widget?
FlTagGroup.build: Widget(BuildContext context)

Reference renders

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

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

Use it to

  • pick interests from a set of tags
  • let users filter a list by categories
  • select several tags at once
  • remove a tag from a keyword list
  • let guests choose amenities like parking or pool
Not for
  • a status shown without a choice: use a chip (chip)
  • an exclusive choice in a form: use radio buttons (radio)
States
  • Selected
  • Unselected
  • Disabled
  • Removable

Source

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

tag_group/fl_tag_group.dart

// Modified from the TagGroup of HeroUI Native 1.0.10, Copyright 2025 NextUI
// Inc., licensed under Apache 2.0: https://www.apache.org/licenses/LICENSE-2.0
import 'package:flutter/semantics.dart' show SemanticsRole;
import 'package:flutter/widgets.dart';
import 'package:fliin_ui/components/field/fl_field.dart';
import 'package:fliin_ui/core/fliin.dart';
import 'package:fliin_ui/icons/fl_icons.dart';

/// The fill of the tags of an [FlTagGroup].
enum FlTagGroupVariant {
  /// The `control` fill.
  primary,

  /// The `surfaceRaised` fill, for a group placed on the page rather than on
  /// a surface.
  surface,
}

/// The size of the tags of an [FlTagGroup]: padding, radius and text.
enum FlTagSize {
  /// `px-2 py-1`, radius `xl`, text `textXs`.
  sm,

  /// `px-3 py-1`, radius `xxl`, text `textSm`.
  md,

  /// `px-4 py-1.5`, radius `radiusCard`, text `textBase`.
  lg,
}

/// What a tap does to a tag.
enum FlTagSelection {
  /// Nothing: the tags are display only.
  none,

  /// At most one tag; tapping it again deselects it.
  single,

  /// As many tags as wanted; each tap toggles its own.
  multiple,
}

/// A tag of an [FlTagGroup], identified by [value].
final class FlTag<T> {
  /// A tag that displays [label].
  const FlTag(this.value, this.label, {this.disabled = false, this.leading});

  /// The value that identifies it in the selection and on removal.
  final T value;

  /// The displayed text, also read by the screen reader.
  final String label;

  /// An icon or a small avatar before the label,
  /// given the tag's text color and a side that follows its size (12, 14
  /// or 16) through the `IconTheme`; left out of the semantics.
  final Widget? leading;

  /// True: at `disabledOpacity`, neither selectable nor removable.
  final bool disabled;
}

/// A group of tags: inline capsules that wrap, picked
/// one or several at a time according to [selection], and removable by
/// their cross when [onRemove] is given.
class FlTagGroup<T> extends StatelessWidget {
  /// A group of the [tags].
  const FlTagGroup({
    super.key,
    required this.tags,
    this.selection = FlTagSelection.none,
    this.selected = const {},
    this.onSelectionChanged,
    this.onRemove,
    this.removeLabel,
    this.variant = FlTagGroupVariant.primary,
    this.size = FlTagSize.md,
    this.disabled = false,
    this.empty,
    this.label,
    this.description,
    this.error,
  }) : assert(onRemove == null || removeLabel != null, 'removeLabel names the cross for the screen reader');

  /// The group's name above the tags ("Amenities"), which names the list
  /// for the screen reader; null, none.
  final String? label;

  /// A help text under the tags; hidden while [error] is non-null.
  final String? description;

  /// Non-null: the selection is rejected, and this text shows under the
  /// tags in `danger` ("Select at least one").
  final String? error;

  /// The tags, in order.
  final List<FlTag<T>> tags;

  /// What a tap does to a tag; `none` by default.
  final FlTagSelection selection;

  /// The values of the chosen tags: `selected`, `onSelected` text. The group
  /// does not keep them: the caller updates them from
  /// [onSelectionChanged].
  final Set<T> selected;

  /// Receives the new selection on each tap.
  final ValueChanged<Set<T>>? onSelectionChanged;

  /// When given: each tag carries a cross that reports it removed.
  final ValueChanged<T>? onRemove;

  /// The name of a tag's cross for the screen reader ("Remove Travel"),
  /// required with [onRemove].
  final String Function(String label)? removeLabel;

  /// The fill; `primary` by default.
  final FlTagGroupVariant variant;

  /// The size; `md` by default.
  final FlTagSize size;

  /// True: the whole group is disabled.
  final bool disabled;

  /// What is shown when [tags] is empty (`renderEmptyState`).
  final Widget? empty;

  void _toggle(T value) {
    final next = switch (selection) {
      FlTagSelection.none => selected,
      FlTagSelection.single => selected.contains(value) ? <T>{} : {value},
      FlTagSelection.multiple => selected.contains(value) ? ({...selected}..remove(value)) : {...selected, value},
    };
    onSelectionChanged?.call(next);
  }

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    final empty = this.empty;
    if (tags.isEmpty && empty != null) return empty;
    final gap = theme.space.xs * 2;
    final label = this.label;
    final description = this.description;
    final error = this.error;
    final list = Semantics(
      container: true,
      role: SemanticsRole.list,
      label: label,
      explicitChildNodes: true,
      // flex-wrap, gap-2, items-start of `tag-group__list`.
      child: Wrap(spacing: gap, runSpacing: gap, children: [for (final tag in tags) _tag(context, theme, tag)]),
    );
    if (label == null && description == null && error == null) return list;
    return FlFieldScope(
      invalid: error != null,
      disabled: disabled,
      required: false,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.stretch,
        spacing: theme.space.sm,
        children: [
          if (label != null) ExcludeSemantics(child: FlLabel(label)),
          list,
          if (description != null && error == null) FlDescription(description),
          if (error != null) FlFieldError(error),
        ],
      ),
    );
  }

  Widget _tag(BuildContext context, FlTheme theme, FlTag<T> tag) {
    final colors = theme.colors;
    final xs = theme.space.xs;
    final on = selected.contains(tag.value);
    final enabled = !disabled && !tag.disabled;
    final rest = switch (variant) {
      FlTagGroupVariant.primary => colors.control,
      FlTagGroupVariant.surface => colors.surfaceRaised,
    };
    final (role, horizontal, vertical, spacing, radius) = switch (size) {
      // 4 above and below, not 2: Flutter's contrast rule looks 4 around the
      // text, and a chosen tag's `selected` fill must fill that.
      FlTagSize.sm => (FlTextRole.textXs, xs * 2, xs, xs, theme.shape.radiusXl),
      FlTagSize.md => (FlTextRole.textSm, xs * 3, xs, xs * 1.5, theme.shape.radiusXxl),
      FlTagSize.lg => (FlTextRole.textBase, xs * 4, xs * 1.5, xs * 1.5, theme.shape.radiusCard),
    };
    // Izydesk's state matrix: hovered or pressed, a `control` tag steps to
    // `controlPressed`.
    // A chosen tag takes the selection color, which fades in over `short2`
    // (a 100 ms color transition).
    Widget body(BuildContext context, {bool hovered = false}) => TweenAnimationBuilder<double>(
      tween: Tween(end: on ? 1 : 0),
      duration: FlMotion.of(context).reduced ? Duration.zero : theme.motion.short2,
      curve: theme.motion.standard,
      builder: (context, t, _) => _body(
        context,
        theme,
        tag,
        fill: Color.lerp(hovered && rest == colors.control ? colors.controlPressed : rest, colors.selected, t)!,
        ink: Color.lerp(colors.onField, colors.onSelected, t)!,
        role: role,
        horizontal: horizontal,
        vertical: vertical,
        spacing: spacing,
        radius: radius,
        enabled: enabled,
      ),
    );
    final Widget item = selection == FlTagSelection.none
        ? Semantics(container: true, role: SemanticsRole.listItem, child: body(context))
        : FlPressable(
            onPressed: enabled ? () => _toggle(tag.value) : null,
            role: SemanticsRole.listItem,
            borderRadius: BorderRadius.circular(radius),
            scaleOnPress: false,
            overlay: FlPressOverlay.none,
            builder: (context, states) => Semantics(
              selected: on,
              child: body(
                context,
                hovered: states.contains(WidgetState.hovered) || states.contains(WidgetState.pressed),
              ),
            ),
          );
    return Opacity(opacity: enabled ? 1 : theme.size.disabledOpacity, child: item);
  }

  Widget _body(
    BuildContext context,
    FlTheme theme,
    FlTag<T> tag, {
    required Color fill,
    required Color ink,
    required FlTextRole role,
    required double horizontal,
    required double vertical,
    required double spacing,
    required double radius,
    required bool enabled,
  }) {
    final onRemove = this.onRemove;
    final removeLabel = this.removeLabel;
    return FlPixelSnap(
      child: DecoratedBox(
        decoration: ShapeDecoration(
          color: fill,
          shape: FlContinuousBorder(radius: radius),
        ),
        child: Padding(
          padding: EdgeInsetsDirectional.symmetric(horizontal: horizontal, vertical: vertical),
          child: Row(
            mainAxisSize: MainAxisSize.min,
            spacing: spacing,
            children: [
              if (tag.leading case final leading?)
                ExcludeSemantics(
                  child: IconTheme.merge(
                    data: IconThemeData(
                      color: ink,
                      size: MediaQuery.textScalerOf(context)
                          .clamp(maxScaleFactor: theme.size.iconMaxScale)
                          .scale(switch (size) {
                            FlTagSize.sm => theme.space.xs * 3,
                            FlTagSize.md => theme.space.xs * 3.5,
                            FlTagSize.lg => theme.space.xs * 4,
                          }),
                    ),
                    child: leading,
                  ),
                ),
              Flexible(
                // Yoga rounds the text box to the pixel: the cross lands after it.
                child: FlPixelSnap(
                  child: Text(
                    tag.label,
                    style: theme.typography
                        .resolve(role)
                        .copyWith(color: ink, fontWeight: theme.tokens.typography.weight.medium),
                    strutStyle: theme.typography.strut(
                      role,
                      inheritedFamily: DefaultTextStyle.of(context).style.fontFamily,
                    ),
                  ),
                ),
              ),
              if (onRemove != null && removeLabel != null)
                _Remove(
                  label: removeLabel(tag.label),
                  color: ink,
                  radius: theme.shape.radiusLg,
                  onPressed: enabled ? () => onRemove(tag.value) : null,
                ),
            ],
          ),
        ),
      ),
    );
  }
}

/// The cross of the canvas's `.tag .i`, 14 per side at 70 %, which no token
/// carries.
const _removeIcon = 14.0;
const _removeOpacity = 0.7;

/// The cross that removes a tag: 14 per side, in the text color at 70 %,
/// with the platform's touch target.
class _Remove extends StatelessWidget {
  const _Remove({required this.label, required this.color, required this.radius, required this.onPressed});

  final String label;
  final Color color;
  final double radius;
  final VoidCallback? onPressed;

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    final side = MediaQuery.textScalerOf(context).clamp(maxScaleFactor: theme.size.iconMaxScale).scale(_removeIcon);
    return FlPressable(
      onPressed: onPressed,
      label: label,
      borderRadius: BorderRadius.circular(radius),
      scaleOnPress: false,
      overlay: FlPressOverlay.none,
      builder: (context, states) => FlIcon(
        FlIcons.xmark,
        size: side,
        color: color.withValues(alpha: color.a * _removeOpacity),
      ),
    );
  }
}

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