Fliin
On this page

Components / forms

Segmented control

Picks one of two to five short options shown side by side, such as day, week or month, with a sliding selection and radio group semantics.

16 of 16 variants pass
Segmented control, example state
Platform
Brightness
Text scale
Direction

Import

In a Flutter app where fliin init has run:

fliin add segmentedAlso installs: core

Examples

Example

Segmented controls switch between a few views of the same content: one choice, always visible. FlSegmentedSize.sm fits a toolbar.

Segmented control, example state
import 'package:your_app/ui/fliin/components/segmented/fl_segmented.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<SegmentedExample> createState() => _SegmentedExampleState();
}

class _SegmentedExampleState extends State<SegmentedExample> {
  var _period = 'week';
  var _billing = 'yearly';

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.stretch,
      spacing: FlScope.of(context).space.md,
      children: [
        FlSegmented<String>(
          semanticLabel: 'Period',
          value: _period,
          onChanged: (value) => setState(() => _period = value),
          segments: const [
            FlSegment(value: 'day', label: 'Day'),
            FlSegment(value: 'week', label: 'Week'),
            FlSegment(value: 'month', label: 'Month'),
          ],
        ),
        FlSegmented<String>(
          semanticLabel: 'Billing',
          value: _billing,
          size: FlSegmentedSize.sm,
          onChanged: (value) => setState(() => _billing = value),
          segments: const [
            FlSegment(value: 'monthly', label: 'Monthly'),
            FlSegment(value: 'yearly', label: 'Yearly'),
          ],
        ),
      ],
    );
  }
}

Selected

The selected segment carries the thumb; the others stay quiet.

Segmented control, selected state
import 'package:your_app/ui/fliin/components/segmented/fl_segmented.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<SegmentedSelectedExample> createState() => _SegmentedSelectedExampleState();
}

class _SegmentedSelectedExampleState extends State<SegmentedSelectedExample> {
  var _period = 'week';

  @override
  Widget build(BuildContext context) {
    return FlSegmented<String>(
      semanticLabel: 'Period',
      value: _period,
      onChanged: (value) => setState(() => _period = value),
      segments: const [
        FlSegment(value: 'day', label: 'Day'),
        FlSegment(value: 'week', label: 'Week'),
        FlSegment(value: 'month', label: 'Month'),
      ],
    );
  }
}

Icons

Each segment can carry an icon before its label; use your app's icons in place of the sample disc.

Segmented control, icons state
import 'package:your_app/ui/fliin/components/segmented/fl_segmented.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<SegmentedIconsExample> createState() => _SegmentedIconsExampleState();
}

class _SegmentedIconsExampleState extends State<SegmentedIconsExample> {
  var _view = 'grid';

  @override
  Widget build(BuildContext context) {
    return FlSegmented<String>(
      semanticLabel: 'View',
      value: _view,
      size: FlSegmentedSize.lg,
      onChanged: (value) => setState(() => _view = value),
      segments: const [
        FlSegment(value: 'list', label: 'List', icon: _Dot()),
        FlSegment(value: 'grid', label: 'Grid', icon: _Dot()),
        FlSegment(value: 'map', label: 'Map', icon: _Dot()),
      ],
    );
  }
}

/// A sample icon: a disc half the icon's size, in its color.
class _Dot extends StatelessWidget {
  const _Dot();

  @override
  Widget build(BuildContext context) {
    final icon = IconTheme.of(context);
    return Center(
      child: SizedBox.square(
        dimension: icon.size! / 2,
        child: DecoratedBox(
          decoration: ShapeDecoration(color: icon.color, shape: const CircleBorder()),
        ),
      ),
    );
  }
}

Disabled

enabled: false keeps the selection visible and locks the control.

Segmented control, disabled state
import 'package:your_app/ui/fliin/components/segmented/fl_segmented.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<SegmentedDisabledExample> createState() => _SegmentedDisabledExampleState();
}

class _SegmentedDisabledExampleState extends State<SegmentedDisabledExample> {
  var _period = 'day';

  @override
  Widget build(BuildContext context) {
    return FlSegmented<String>(
      semanticLabel: 'Period',
      value: _period,
      enabled: false,
      onChanged: (value) => setState(() => _period = value),
      segments: const [
        FlSegment(value: 'day', label: 'Day'),
        FlSegment(value: 'week', label: 'Week'),
        FlSegment(value: 'month', label: 'Month'),
      ],
    );
  }
}

Sizes

Three heights: sm (36), md (40, the default) and lg (48).

Segmented control, sizes state
import 'package:your_app/ui/fliin/components/segmented/fl_segmented.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<SegmentedSizesExample> createState() => _SegmentedSizesExampleState();
}

class _SegmentedSizesExampleState extends State<SegmentedSizesExample> {
  var _period = 'week';

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.stretch,
      spacing: FlScope.of(context).space.md,
      children: [
        for (final size in FlSegmentedSize.values)
          FlSegmented<String>(
            key: ValueKey(size),
            semanticLabel: 'Period',
            value: _period,
            size: size,
            onChanged: (value) => setState(() => _period = value),
            segments: const [
              FlSegment(value: 'day', label: 'Day'),
              FlSegment(value: 'week', label: 'Week'),
              FlSegment(value: 'month', label: 'Month'),
            ],
          ),
      ],
    );
  }
}

Icon only

iconOnly shows the icon alone; the label still names the segment for the screen reader. expand: false keeps the group to its own width.

Segmented control, icon-only state
import 'package:your_app/ui/fliin/components/segmented/fl_segmented.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 SegmentedIconOnlyExample extends StatefulWidget {
  const SegmentedIconOnlyExample({super.key});

  @override
  State<SegmentedIconOnlyExample> createState() => _SegmentedIconOnlyExampleState();
}

class _SegmentedIconOnlyExampleState extends State<SegmentedIconOnlyExample> {
  var _view = 'list';

  @override
  Widget build(BuildContext context) {
    return FlSegmented<String>(
      semanticLabel: 'View',
      value: _view,
      expand: false,
      onChanged: (value) => setState(() => _view = value),
      segments: [
        FlSegment(value: 'list', label: 'List', icon: FlIcon(FlIcons.bars), iconOnly: true),
        FlSegment(value: 'calendar', label: 'Calendar', icon: FlIcon(FlIcons.calendar), iconOnly: true),
        FlSegment(value: 'map', label: 'Map', icon: FlIcon(FlIcons.mapPin), iconOnly: true),
      ],
    );
  }
}

Compact

expand: false: each segment takes its own width and the group sits at the start, beside a title here, instead of filling the row.

Segmented control, compact state
import 'package:your_app/ui/fliin/components/segmented/fl_segmented.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<SegmentedCompactExample> createState() => _SegmentedCompactExampleState();
}

class _SegmentedCompactExampleState extends State<SegmentedCompactExample> {
  var _sort = 'recent';

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    return Row(
      spacing: theme.space.md,
      children: [
        Expanded(
          child: Text(
            'Comments',
            style: theme.typography.resolve(FlTextRole.textBase).copyWith(color: theme.colors.onSurface),
          ),
        ),
        FlSegmented<String>(
          semanticLabel: 'Sort',
          value: _sort,
          size: FlSegmentedSize.sm,
          expand: false,
          onChanged: (value) => setState(() => _sort = value),
          segments: const [
            FlSegment(value: 'recent', label: 'Recent'),
            FlSegment(value: 'top', label: 'Top'),
          ],
        ),
      ],
    );
  }
}

Usage

The code the reference images render:

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.sm, children: [FlSegmented<String>(semanticLabel: 'Period', value: 'Week', onChanged: (_) {}, segments: [for (final (fr, ar) in const [('Day', 'يوم'), ('Week', 'أسبوع'), ('Month', 'شهر')]) FlSegment(value: fr, label: fr]), FlSegmented<String>(semanticLabel: 'Billing', value: 'Yearly', size: FlSegmentedSize.sm, onChanged: (_) {}, segments: [for (final (fr, ar) in const [('Monthly', 'شهري'), ('Yearly', 'سنوي')]) FlSegment(value: fr, label: fr]), FlSegmented<String>(semanticLabel: 'View', value: 'Grid', size: FlSegmentedSize.lg, onChanged: (_) {}, segments: [for (final (fr, ar) in const [('List', 'قائمة'), ('Grid', 'شبكة'), ('Map', 'خريطة')]) FlSegment(value: fr, label: fr, icon: Builder(builder: (context) => Center(child: SizedBox.square(dimension: IconTheme.of(context).size! / 2, child: DecoratedBox(decoration: ShapeDecoration(color: IconTheme.of(context).color, shape: const CircleBorder()))))))]), FlSegmented<String>(semanticLabel: 'Duration', value: '30 d', onChanged: (_) {}, segments: [for (final (i, (fr, ar)) in const [('7 d', '٧ أيام'), ('30 d', '٣٠ يومًا'), ('90 d', '٩٠ يومًا'), ('6 mo', '٦ أشهر'), ('1 year', 'سنة')].indexed) FlSegment(value: fr, label: fr, disabled: i == 3)]), FlSegmented<String>(semanticLabel: 'Theme', value: 'Light', enabled: false, onChanged: (_) {}, segments: [for (final (fr, ar) in const [('Light', 'فاتح'), ('Dark', 'داكن')]) FlSegment(value: fr, label: fr])])))

API

FlSegmentedSize: enum {sm, md, lg}
FlSegmentedSize.sm: static final FlSegmentedSize
FlSegmentedSize.md: static final FlSegmentedSize
FlSegmentedSize.lg: static final FlSegmentedSize
FlSegment: final class<T>
FlSegment.new: const new({required T value, required String label, Widget? icon, bool iconOnly, bool disabled, WidgetStatesController? statesController})
FlSegment.value: get T
FlSegment.label: get String
FlSegment.icon: get Widget?
FlSegment.iconOnly: get bool
FlSegment.disabled: get bool
FlSegment.statesController: get WidgetStatesController?
FlSegmented: class<T> extends StatefulWidget
FlSegmented.new: const new({super.key, required List<FlSegment<T>> segments, required T value, required ValueChanged<T> onChanged, FlSegmentedSize size, bool enabled, bool expand, String? semanticLabel})
FlSegmented.segments: get List<FlSegment<T>>
FlSegmented.value: get T
FlSegmented.onChanged: get ValueChanged<T>
FlSegmented.size: get FlSegmentedSize
FlSegmented.enabled: get bool
FlSegmented.expand: get bool
FlSegmented.semanticLabel: get String?
FlSegmented.createState: State<FlSegmented<T>>()

Reference renders

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

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

Use it to

  • pick day, week or month above a chart
  • switch between monthly and yearly billing
  • switch the results from a list to a grid
  • offer a few short options as joined buttons
  • choose a time range from buttons side by side
  • make a group of toggle buttons where one stays pressed
  • use a segmented control to filter what a chart shows
Not for
  • sections or categories that change the whole view: use tabs (tabs)
  • long options, or more than five: use radio buttons (radio)
  • a long list of choices in a form: use a select (select)
  • a continuous value between a minimum and a maximum: use a slider (slider)
States
  • Selected
  • Icons
  • Disabled

Source

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

segmented/fl_segmented.dart

// Modified from the ToggleButtonGroup and ToggleButton of HeroUI React v3,
// Copyright 2020 Next UI Inc., licensed under the MIT license below and under
// Apache 2.0: https://www.apache.org/licenses/LICENSE-2.0
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
// IN THE SOFTWARE.
//
// Also modified from the theme colors of HeroUI Native 1.0.10, Copyright 2025
// NextUI Inc., licensed under Apache 2.0.
import 'dart:math' as math;
import 'dart:ui' show lerpDouble;

import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:fliin_ui/core/fliin.dart';

/// The inset of the segments within the capsule, the `padding: 4px` of the
/// Izydesk segmented control, which no token carries.
const double _inset = 4;

/// The icon margin (`-mx-0.5`): it bleeds by 2 on each side.
const double _iconBleed = 0.5;

/// The height of an [FlSegmented], sized for a phone.
enum FlSegmentedSize {
  /// 36 high, `px-3`, `textXs` text, 16 icon.
  sm,

  /// 40 high, `px-3`, `textSm` text, 16 icon.
  md,

  /// 48 high, `px-3`, `textBase` text, 16 icon.
  lg,
}

/// A segment of an [FlSegmented]: the [value] it picks, its displayed and
/// read [label], and an optional [icon] before it.
final class FlSegment<T> {
  /// A segment that picks [value].
  const FlSegment({
    required this.value,
    required this.label,
    this.icon,
    this.iconOnly = false,
    this.disabled = false,
    this.statesController,
  }) : assert(!iconOnly || icon != null, 'an icon-only segment needs its icon');

  /// The value the segment gives the group.
  final T value;

  /// The segment text, short: one or two words. It wraps between two words
  /// when space runs out. Read by the screen reader, shown unless
  /// [iconOnly].
  final String label;

  /// An icon before the label, given the segment's color and size through
  /// the `IconTheme`.
  final Widget? icon;

  /// True: the segment shows its [icon] alone (a list or grid view, a text
  /// alignment), and [label] only names it for the screen reader.
  final bool iconOnly;

  /// True: the segment is faded, with neither touch nor focus, and skipped
  /// by the arrow keys. The selected segment must not be.
  final bool disabled;

  /// The segment's states, which the app can read or force (a hovered or
  /// focused preview), added to its own; see `FlPressable.statesController`.
  final WidgetStatesController? statesController;
}

/// An exclusive choice between two and five short segments (day, week,
/// month): a `control` track holding the segments, inset by 4 from its edge,
/// the chosen segment a `segment` thumb lifted by the `field` shadow with
/// `onSegment` text, the others in `onSurfaceMuted` without fill or rule. The segments share the width; a segment with a
/// word that does not fit takes that word's width, and the others share the
/// rest. The thumb slides to the new choice with the
/// `segment` spring, and lands at once under reduced motion.
///
/// The screen reader reads a radio group named [semanticLabel]. With the
/// keyboard, the group is a single Tab stop; the arrow keys pick the
/// neighboring segment in reading order (reversed in RTL) and wrap around
/// to the first after the last, Home and End pick the first and the last.
class FlSegmented<T> extends StatefulWidget {
  /// A group whose selected segment carries [value].
  const FlSegmented({
    super.key,
    required this.segments,
    required this.value,
    required this.onChanged,
    this.size = FlSegmentedSize.md,
    this.enabled = true,
    this.expand = true,
    this.semanticLabel,
  }) : assert(segments.length >= 2 && segments.length <= 5, 'two to five segments');

  /// The segments, in reading order.
  final List<FlSegment<T>> segments;

  /// The value of the selected segment: that of one of the [segments].
  final T value;

  /// Receives the value of the segment tapped or reached by keyboard; the
  /// parent passes it back in [value].
  final ValueChanged<T> onChanged;

  /// The height and the text; `md` by default.
  final FlSegmentedSize size;

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

  /// True (the default): the group takes the available width, shared by
  /// its segments. False: each segment takes its own width, and the group
  /// sits at the start of its space (a compact choice in a toolbar or a
  /// header).
  final bool expand;

  /// The group name for the screen reader ("Period").
  final String? semanticLabel;

  @override
  State<FlSegmented<T>> createState() => _FlSegmentedState<T>();
}

class _StepIntent extends Intent {
  const _StepIntent(this.step, {this.edge = false});

  /// +1 toward the end, -1 toward the start; with [edge], all the way.
  final int step;
  final bool edge;
}

class _FlSegmentedState<T> extends State<FlSegmented<T>> with SingleTickerProviderStateMixin {
  late final AnimationController _move = AnimationController.unbounded(vsync: this, value: 1);
  final _Spans _spans = _Spans();
  late List<FocusNode> _nodes = _makeNodes();

  /// The background at the start of the current move; null, it is at rest.
  Rect? _from;
  int? _pressed;
  int? _hovered;
  int? _focused;

  List<FocusNode> _makeNodes() => [
    for (var i = 0; i < widget.segments.length; i++) FocusNode(debugLabel: 'FlSegment $i'),
  ];

  int get _selected => widget.segments.indexWhere((segment) => segment.value == widget.value);

  @override
  void didUpdateWidget(FlSegmented<T> oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.segments.length != widget.segments.length) {
      final hadFocus = _nodes.any((node) => node.hasFocus);
      for (final node in _nodes) {
        node.dispose();
      }
      _nodes = _makeNodes();
      _from = null;
      _move.value = 1;
      if (hadFocus) {
        WidgetsBinding.instance.addPostFrameCallback((_) => _nodes[_selected].requestFocus());
      }
      return;
    }
    final before = oldWidget.segments.indexWhere((segment) => segment.value == oldWidget.value);
    if (before == _selected) return;
    if (FlMotion.of(context).reduced) {
      _from = null;
      _move.value = 1;
      return;
    }
    _from = _pillAt(before);
    _move.value = 0;
    _move.driveTo(1, FlSpringMotion(FlMotion.of(context).tokens.segment));
  }

  /// Where the background is painted right now, if it were heading to
  /// [index].
  Rect? _pillAt(int index) => _Pill.current(_spans, _from, index, _move.value);

  @override
  void dispose() {
    for (final node in _nodes) {
      node.dispose();
    }
    _move.dispose();
    super.dispose();
  }

  /// The states the app forces on segment [i] through its controller.
  Set<WidgetState> _forced(int i) => widget.segments[i].statesController?.value ?? const {};

  /// The hovered or pressed segment, the app's forced ones included.
  int? get _highlighted {
    if (_pressed ?? _hovered case final i?) return i;
    for (var i = 0; i < widget.segments.length; i++) {
      final forced = _forced(i);
      if (forced.contains(WidgetState.hovered) || forced.contains(WidgetState.pressed)) {
        return i;
      }
    }
    return null;
  }

  @override
  Widget build(BuildContext context) {
    final controllers = [for (final segment in widget.segments) ?segment.statesController];
    if (controllers.isEmpty) return _build(context);
    return ListenableBuilder(listenable: Listenable.merge(controllers), builder: (context, _) => _build(context));
  }

  void _choose(int index) {
    final segment = widget.segments[index];
    if (segment.value != widget.value) widget.onChanged(segment.value);
  }

  void _step(_StepIntent intent) {
    final enabled = [
      for (var i = 0; i < widget.segments.length; i++)
        if (!widget.segments[i].disabled) i,
    ];
    final at = enabled.indexOf(_selected);
    final next = switch ((intent.edge, intent.step > 0)) {
      (true, true) => enabled.last,
      (true, false) => enabled.first,
      (false, _) => enabled[(at + intent.step) % enabled.length],
    };
    _choose(next);
    _nodes[next].requestFocus();
  }

  Widget _build(BuildContext context) {
    final selected = _selected;
    assert(selected >= 0, 'value must be that of one of the segments');
    assert(!widget.segments[selected].disabled, 'the selected segment must not be disabled');
    final theme = FlScope.of(context);
    final colors = theme.colors;
    final forward = Directionality.of(context) == TextDirection.ltr ? 1 : -1;
    final segments = widget.segments;
    final look = _Look.of(context, theme, widget.size);
    Color ink(int i) => i == selected ? colors.onSegment : colors.onSurfaceMuted;
    final radius = _innerRadius(theme);
    final group = DecoratedBox(
      decoration: BoxDecoration(color: colors.control, borderRadius: BorderRadius.circular(theme.shape.radiusButton)),
      child: Padding(
        padding: const EdgeInsets.all(_inset),
        child: LayoutBuilder(
          builder: (context, constraints) {
            final padding = _padding(context, look, constraints.maxWidth);
            return CustomPaint(
              painter: _Pill(
                move: _move,
                from: _from,
                selected: selected,
                spans: _spans,
                radius: radius,
                hover: colors.controlPressed,
                fill: colors.segment,
                // The three layers of `field`.
                shadows: theme.shadows.field,
                // Pressed, the thumb takes the press highlight of a surface.
                fillPressed: Color.alphaBlend(
                  colors.pressHighlight.withValues(alpha: theme.size.pressHighlightOpacity),
                  colors.segment,
                ),
                highlighted: _highlighted,
                disabled: {
                  for (var i = 0; i < segments.length; i++)
                    if (segments[i].disabled) i,
                },
              ),
              child: _SegmentRow(
                spans: _spans,
                pixelRatio: MediaQuery.devicePixelRatioOf(context),
                expand: widget.expand,
                children: [
                  for (var i = 0; i < segments.length; i++)
                    _segment(theme, look, i, padding: padding, selected: i == selected, color: ink(i)),
                ],
              ),
            );
          },
        ),
      ),
    );
    final Widget result = Shortcuts(
      shortcuts: {
        SingleActivator(LogicalKeyboardKey.arrowRight): _StepIntent(forward),
        SingleActivator(LogicalKeyboardKey.arrowLeft): _StepIntent(-forward),
        const SingleActivator(LogicalKeyboardKey.arrowDown): const _StepIntent(1),
        const SingleActivator(LogicalKeyboardKey.arrowUp): const _StepIntent(-1),
        const SingleActivator(LogicalKeyboardKey.home): const _StepIntent(-1, edge: true),
        const SingleActivator(LogicalKeyboardKey.end): const _StepIntent(1, edge: true),
      },
      child: Actions(
        actions: {_StepIntent: CallbackAction<_StepIntent>(onInvoke: _step)},
        child: Semantics(
          container: true,
          role: SemanticsRole.radioGroup,
          label: widget.semanticLabel,
          explicitChildNodes: true,
          child: Opacity(opacity: widget.enabled ? 1 : theme.size.disabledOpacity, child: group),
        ),
      ),
    );
    return widget.expand
        ? result
        : Align(alignment: AlignmentDirectional.centerStart, widthFactor: 1, heightFactor: 1, child: result);
  }

  /// The radius of a segment, concentric with the capsule: the button's
  /// minus the inset.
  double _innerRadius(FlTheme theme) => math.max(0, theme.shape.radiusButton - _inset);

  /// The margin on each side of a label, reduced down to
  /// `space.xs` when the longest word of each segment wouldn't otherwise fit
  /// in [available]. Beyond that, a word wider than its share breaks, as
  /// everywhere else.
  double _padding(BuildContext context, _Look look, double available) {
    if (!available.isFinite) return look.padding;
    final words = widget.segments.fold<double>(0, (sum, segment) {
      final painter = TextPainter(
        text: TextSpan(text: segment.label, style: DefaultTextStyle.of(context).style.merge(look.text)),
        strutStyle: look.strut,
        textScaler: MediaQuery.textScalerOf(context),
        textDirection: Directionality.of(context),
      )..layout();
      final word = segment.iconOnly ? 0.0 : painter.minIntrinsicWidth;
      painter.dispose();
      return sum + math.max(word, segment.icon == null ? 0 : look.icon - 2 * look.bleed);
    });
    final room = (available - words) / (2 * widget.segments.length);
    return room.clamp(look.minPadding, look.padding).floorToDouble();
  }

  Widget _segment(
    FlTheme theme,
    _Look look,
    int i, {
    required double padding,
    required bool selected,
    required Color color,
  }) {
    final segment = widget.segments[i];
    final enabled = widget.enabled && !segment.disabled;
    _nodes[i].skipTraversal = !selected;
    final icon = segment.icon;
    final label = Text(
      segment.label,
      textAlign: TextAlign.center,
      style: look.text.copyWith(color: color),
      strutStyle: look.strut,
    );
    // The icon and the label on one line, and the label moves below when
    // the two don't fit side by side.
    final content = Wrap(
      alignment: WrapAlignment.center,
      crossAxisAlignment: WrapCrossAlignment.center,
      // gap-2 from `toggle-button`.
      spacing: theme.space.sm,
      runSpacing: theme.space.xs,
      children: [
        if (icon != null)
          SizedBox(
            width: look.icon - 2 * look.bleed,
            height: look.icon,
            child: OverflowBox(
              maxWidth: look.icon,
              child: IconTheme.merge(
                data: IconThemeData(color: color, size: look.icon),
                child: icon,
              ),
            ),
          ),
        if (!segment.iconOnly) label,
      ],
    );
    void choose() => _choose(i);
    return FlHitTarget(
      child: Semantics(
        inMutuallyExclusiveGroup: true,
        checked: selected,
        enabled: enabled,
        label: segment.label,
        onTap: enabled ? choose : null,
        child: FocusableActionDetector(
          focusNode: _nodes[i],
          enabled: enabled,
          mouseCursor: enabled ? SystemMouseCursors.click : MouseCursor.defer,
          actions: {ActivateIntent: CallbackAction<ActivateIntent>(onInvoke: (_) => choose())},
          onShowHoverHighlight: (visible) => setState(() => _hovered = visible ? i : (_hovered == i ? null : _hovered)),
          onShowFocusHighlight: (visible) => setState(() => _focused = visible ? i : (_focused == i ? null : _focused)),
          child: GestureDetector(
            behavior: HitTestBehavior.opaque,
            excludeFromSemantics: true,
            onTapDown: enabled ? (_) => setState(() => _pressed = i) : null,
            onTapCancel: enabled ? () => setState(() => _pressed = null) : null,
            onTapUp: enabled ? (_) => setState(() => _pressed = null) : null,
            onTap: enabled ? choose : null,
            // Izydesk's focused segment: a ring 2 from its edge, within the
            // capsule's inset.
            child: FlFocusRing(
              visible: _focused == i || _forced(i).contains(WidgetState.focused),
              borderRadius: BorderRadius.circular(_innerRadius(theme)),
              child: Opacity(
                opacity: segment.disabled ? theme.size.disabledOpacity : 1,
                child: ConstrainedBox(
                  constraints: BoxConstraints(minHeight: look.height - 2 * _inset),
                  child: Padding(
                    padding: EdgeInsetsDirectional.symmetric(horizontal: padding, vertical: theme.space.xs),
                    child: Center(widthFactor: 1, child: ExcludeSemantics(child: content)),
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

/// What the size sets for a segment: height, margin, text and icon.
final class _Look {
  const _Look({
    required this.height,
    required this.padding,
    required this.minPadding,
    required this.text,
    required this.strut,
    required this.icon,
    required this.bleed,
  });

  factory _Look.of(BuildContext context, FlTheme theme, FlSegmentedSize size) {
    final xs = theme.space.xs;
    final (height, padding, role, icon) = switch (size) {
      FlSegmentedSize.sm => (xs * 9, xs * 3, FlTextRole.textXs, xs * 4),
      FlSegmentedSize.md => (xs * 10, xs * 3, FlTextRole.textSm, xs * 4),
      // Izydesk's `.seg-lg` keeps the 16 icon of `.seg`.
      FlSegmentedSize.lg => (xs * 12, xs * 3, FlTextRole.textBase, xs * 4),
    };
    return _Look(
      height: height,
      padding: padding,
      minPadding: xs,
      text: theme.typography.resolve(role).copyWith(fontWeight: theme.tokens.typography.weight.medium),
      strut: theme.typography.strut(role, inheritedFamily: DefaultTextStyle.of(context).style.fontFamily),
      icon: MediaQuery.textScalerOf(context).clamp(maxScaleFactor: theme.size.iconMaxScale).scale(icon),
      bleed: xs * _iconBleed,
    );
  }

  /// 36, 40 or 48: the group's minimum height, inset included.
  final double height;

  /// `px-3`, and the narrowest margin when space runs out.
  final double padding;
  final double minPadding;

  final TextStyle text;
  final StrutStyle strut;

  /// The icon side, scaled with the text up to `size.iconMaxScale`, and its
  /// bleed on each side.
  final double icon;
  final double bleed;
}

/// The place of each segment in the group, left to right on screen,
/// recorded at layout and read by the background at paint in the same
/// frame.
final class _Spans {
  List<Rect> rects = const [];

  /// Segment [index] in reading order.
  Rect? rect(int index) => index < rects.length ? rects[index] : null;
}

/// The segments' capsules: the hovered or pressed segment's in
/// `controlPressed`, unless it is disabled, then the chosen segment's thumb
/// in `segment` over its shadow (with the press highlight when pressed),
/// which slides from [from] to its place following [move].
class _Pill extends CustomPainter {
  _Pill({
    required this.move,
    required this.from,
    required this.selected,
    required this.spans,
    required this.radius,
    required this.hover,
    required this.fill,
    required this.shadows,
    required this.fillPressed,
    required this.highlighted,
    required this.disabled,
  }) : super(repaint: move);

  final Animation<double> move;
  final Rect? from;
  final int selected;
  final _Spans spans;
  final double radius;
  final Color hover;
  final Color fill;
  final List<FlShadowLayer> shadows;
  final Color fillPressed;
  final int? highlighted;
  final Set<int> disabled;

  /// The background's place when the move toward [index] is at [t].
  static Rect? current(_Spans spans, Rect? from, int index, double t) {
    final to = spans.rect(index);
    if (from == null || to == null) return to;
    return Rect.fromLTRB(lerpDouble(from.left, to.left, t)!, to.top, lerpDouble(from.right, to.right, t)!, to.bottom);
  }

  @override
  void paint(Canvas canvas, Size size) {
    final pill = current(spans, from, selected, move.value);
    if (pill == null) return;
    final corner = Radius.circular(radius);
    final highlighted = this.highlighted;
    if (highlighted != null && highlighted != selected && !disabled.contains(highlighted)) {
      if (spans.rect(highlighted) case final rect?) {
        canvas.drawRRect(RRect.fromRectAndRadius(rect, corner), Paint()..color = hover);
      }
    }
    final thumb = RRect.fromRectAndRadius(pill, corner);
    // CSS box-shadow: a Gaussian blur whose standard deviation is half the
    // blur radius.
    for (final layer in shadows) {
      if (layer.color.a == 0) continue;
      canvas.drawRRect(
        thumb.inflate(layer.spread).shift(layer.offset),
        Paint()
          ..color = layer.color
          ..maskFilter = layer.blur > 0 ? MaskFilter.blur(BlurStyle.normal, layer.blur / 2) : null,
      );
    }
    canvas.drawRRect(thumb, Paint()..color = highlighted == selected ? fillPressed : fill);
  }

  @override
  bool shouldRepaint(_Pill oldDelegate) =>
      move != oldDelegate.move ||
      from != oldDelegate.from ||
      selected != oldDelegate.selected ||
      spans != oldDelegate.spans ||
      radius != oldDelegate.radius ||
      hover != oldDelegate.hover ||
      fill != oldDelegate.fill ||
      !identical(shadows, oldDelegate.shadows) ||
      fillPressed != oldDelegate.fillPressed ||
      highlighted != oldDelegate.highlighted ||
      !_sameSet(disabled, oldDelegate.disabled);

  static bool _sameSet(Set<int> a, Set<int> b) => a.length == b.length && a.containsAll(b);
}

/// The row of segments: each takes an equal share of the width; a segment
/// wider than its share (the longest word of its label) keeps its minimum
/// width, and the others share the rest. If even the minimum widths don't
/// fit, they shrink together. Edges land on the pixel grid, as under Yoga,
/// and all segments take the height of the tallest. Without a bounded
/// width, each takes its natural width.
class _SegmentRow extends MultiChildRenderObjectWidget {
  const _SegmentRow({required this.spans, required this.pixelRatio, required this.expand, required super.children});

  final _Spans spans;
  final double pixelRatio;
  final bool expand;

  @override
  RenderObject createRenderObject(BuildContext context) =>
      _RenderSegmentRow(spans, pixelRatio, Directionality.of(context), expand);

  @override
  void updateRenderObject(BuildContext context, _RenderSegmentRow renderObject) {
    renderObject
      ..pixelRatio = pixelRatio
      ..textDirection = Directionality.of(context)
      ..expand = expand;
  }
}

class _SegmentParentData extends ContainerBoxParentData<RenderBox> {}

class _RenderSegmentRow extends RenderBox
    with
        ContainerRenderObjectMixin<RenderBox, _SegmentParentData>,
        RenderBoxContainerDefaultsMixin<RenderBox, _SegmentParentData> {
  _RenderSegmentRow(this.spans, this._pixelRatio, this._textDirection, this._expand);

  final _Spans spans;

  bool _expand;
  set expand(bool value) {
    if (value == _expand) return;
    _expand = value;
    markNeedsLayout();
  }

  double _pixelRatio;
  set pixelRatio(double value) {
    if (value == _pixelRatio) return;
    _pixelRatio = value;
    markNeedsLayout();
  }

  TextDirection _textDirection;
  set textDirection(TextDirection value) {
    if (value == _textDirection) return;
    _textDirection = value;
    markNeedsLayout();
  }

  @override
  void setupParentData(RenderBox child) {
    if (child.parentData is! _SegmentParentData) {
      child.parentData = _SegmentParentData();
    }
  }

  List<RenderBox> get _children => [for (var child = firstChild; child != null; child = childAfter(child)) child];

  @override
  double computeMinIntrinsicWidth(double height) =>
      _children.fold(0, (sum, child) => sum + child.getMinIntrinsicWidth(height));

  @override
  double computeMaxIntrinsicWidth(double height) =>
      _children.fold(0, (sum, child) => sum + child.getMaxIntrinsicWidth(height));

  @override
  double computeMinIntrinsicHeight(double width) {
    final children = _children;
    final widths = _widths(width, children);
    return [for (var i = 0; i < children.length; i++) children[i].getMinIntrinsicHeight(widths[i])].fold(0, math.max);
  }

  @override
  double computeMaxIntrinsicHeight(double width) {
    final children = _children;
    final widths = _widths(width, children);
    return [for (var i = 0; i < children.length; i++) children[i].getMaxIntrinsicHeight(widths[i])].fold(0, math.max);
  }

  /// The segment widths within [available], their edges on the pixel grid.
  /// A segment that keeps its minimum width rounds it up to the next pixel,
  /// so its longest word fits.
  List<double> _widths(double available, List<RenderBox> children) {
    double snapUp(double w) => (w * _pixelRatio).ceilToDouble() / _pixelRatio;
    double snap(double w) => (w * _pixelRatio).roundToDouble() / _pixelRatio;
    if (!available.isFinite || !_expand) {
      final natural = [for (final child in children) snapUp(child.getMaxIntrinsicWidth(double.infinity))];
      // Without expand, each segment keeps its own width while they fit.
      if (!available.isFinite || natural.fold<double>(0, (sum, w) => sum + w) <= available) {
        return natural;
      }
    }
    final mins = [for (final child in children) snapUp(child.getMinIntrinsicWidth(double.infinity))];
    final total = mins.fold<double>(0, (sum, w) => sum + w);
    final kept = <int>{};
    var share = available / children.length;
    if (total > available) {
      kept.addAll(List.generate(children.length, (i) => i));
    } else {
      while (true) {
        final wider = [
          for (var i = 0; i < mins.length; i++)
            if (!kept.contains(i) && mins[i] > share) i,
        ];
        if (wider.isEmpty) break;
        kept.addAll(wider);
        share = (available - kept.fold<double>(0, (sum, i) => sum + mins[i])) / (children.length - kept.length);
      }
    }
    final widths = <double>[];
    var edge = 0.0;
    var snapped = 0.0;
    for (var i = 0; i < children.length; i++) {
      edge += total > available ? available * mins[i] / total : (kept.contains(i) ? mins[i] : share);
      final next = snap(edge);
      widths.add(next - snapped);
      snapped = next;
    }
    return widths;
  }

  Size _layout(BoxConstraints constraints, {required bool dry}) {
    final children = _children;
    if (children.isEmpty) return constraints.smallest;
    final widths = _widths(constraints.maxWidth, children);
    final height = [
      for (var i = 0; i < children.length; i++)
        dry
            ? children[i].getDryLayout(BoxConstraints.tightFor(width: widths[i])).height
            : (children[i]..layout(BoxConstraints.tightFor(width: widths[i]), parentUsesSize: true)).size.height,
    ].fold<double>(constraints.minHeight, math.max);
    final width = widths.fold<double>(0, (sum, w) => sum + w);
    if (!dry) {
      final ltr = _textDirection == TextDirection.ltr;
      final rects = <Rect>[];
      var x = ltr ? 0.0 : width;
      for (var i = 0; i < children.length; i++) {
        final child = children[i]..layout(BoxConstraints.tightFor(width: widths[i], height: height));
        final left = ltr ? x : x - widths[i];
        (child.parentData! as _SegmentParentData).offset = Offset(left, 0);
        rects.add(Rect.fromLTWH(left, 0, widths[i], height));
        x = ltr ? x + widths[i] : x - widths[i];
      }
      spans.rects = rects;
    }
    return constraints.constrain(Size(width, height));
  }

  @override
  Size computeDryLayout(BoxConstraints constraints) => _layout(constraints, dry: true);

  @override
  void performLayout() => size = _layout(constraints, dry: false);

  @override
  void paint(PaintingContext context, Offset offset) => defaultPaint(context, offset);

  @override
  bool hitTestChildren(BoxHitTestResult result, {required Offset position}) =>
      defaultHitTestChildren(result, position: position);
}

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