RemixRemix
Navigation

Segmented Control

An equal-segment, controlled single-select control with roving keyboard focus

Open catalog
Starting Flutter example…
Live Flutter from the pinned Remix component catalog. Examples include Fortal styling; the source tab shows the catalog code, not a standalone application.

A segmented control switches between a small set of mutually exclusive views or modes. It has a persistent track, equal segment extents, and a selected item surface. Selection is controlled by selectedValue; activating the selected item does not clear it or emit another change.

The value type must be non-nullable (T extends Object). null is reserved for selectedValue to represent no selection; item values themselves cannot be null, and onChanged only emits non-null item values.

FortalSegmentedControl<T> supplies the owned track, sizes, surface variant, typography, selected fill, shared focus ring, and disabled precedence. A raw RemixSegmentedControl remains intentionally unstyled.

Fortal recipe

Fortal segmented control

import 'package:flutter/foundation.dart';
import 'package:remix/remix.dart';
import 'ui/ui.dart';

FortalSegmentedControl<String> reportingPeriod({
  required String value,
  required ValueChanged<String> onChanged,
}) {
  return FortalSegmentedControl<String>.surface(
    semanticLabel: 'Reporting period',
    size: FortalSegmentedControlSize.size2,
    items: const [
      RemixSegmentedControlItem(value: 'day', label: 'Day'),
      RemixSegmentedControlItem(value: 'week', label: 'Week'),
      RemixSegmentedControlItem(value: 'month', label: 'Month'),
    ],
    selectedValue: value,
    onChanged: onChanged,
  );
}

Basic implementation

Controlled segmented control

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

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

  @override
  State<ReportingPeriodControl> createState() =>
      _ReportingPeriodControlState();
}

class _ReportingPeriodControlState extends State<ReportingPeriodControl> {
  String _value = 'week';

  @override
  Widget build(BuildContext context) {
    final colors = Theme.of(context).colorScheme;
    final disabledForeground = colors.onSurface.withValues(alpha: 0.35);
    final itemStyle = SegmentedControlItemStyler()
        .padding(.horizontal(12))
        .padding(.vertical(8))
        .spacing(6)
        .borderRadius(BorderRadiusGeometryMix.circular(7))
        .labelColor(colors.onSurfaceVariant)
        .iconColor(colors.onSurfaceVariant)
        .onSelected(
          .color(colors.surface)
              .labelColor(colors.onSurface)
              .iconColor(colors.onSurface)
              .containerEffects(
                RemixBoxEffectsMix(
                  behindContent: RemixBoxEffectLayerMix(
                    shadows: [
                      RemixBoxShadowMix(
                        color: colors.shadow.withValues(alpha: 0.18),
                        offset: const Offset(0, 1),
                        blurRadius: 3,
                      ),
                    ],
                  ),
                ),
              ),
        )
        .onFocusVisible(
          .containerEffects(
            RemixBoxEffectsMix(
              outline: BorderSideMix(
                color: colors.primary,
                width: 2,
                strokeAlign: BorderSide.strokeAlignInside,
              ),
              outlineOffset: 2,
            ),
          ),
        )
        .onDisabled(
          .label(TextStyler().color(disabledForeground))
              .iconColor(disabledForeground),
        );

    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        RemixSegmentedControl<String>(
          semanticLabel: 'Reporting period',
          items: const [
            RemixSegmentedControlItem(value: 'day', label: 'Day'),
            RemixSegmentedControlItem(value: 'week', label: 'This week'),
            RemixSegmentedControlItem(value: 'month', label: 'Month'),
            RemixSegmentedControlItem(
              value: 'year',
              label: 'Year',
              enabled: false,
            ),
          ],
          selectedValue: _value,
          onChanged: (value) => setState(() => _value = value),
          style: SegmentedControlStyler()
              .padding(.all(4))
              .borderRadius(BorderRadiusGeometryMix.circular(10))
              .color(colors.surfaceContainerHighest)
              .item(itemStyle),
        ),
        const SizedBox(height: 8),
        Text('Selected: $_value'),
      ],
    );
  }
}

Icon-only items

Icon-only segments must provide a nonblank semanticLabel. Labels and semantic labels containing only whitespace are rejected in debug builds. Naked owns the single accessible button node; the visual icon and text are excluded beneath it, so names and selected state are announced exactly once.

Icon-only layout selector

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

Widget layoutSelector({
  required String value,
  required ValueChanged<String> onChanged,
  // Required, not optional: there is no themed preset to fall back on.
  required SegmentedControlStyler style,
}) {
  return RemixSegmentedControl<String>(
    semanticLabel: 'Layout',
    items: const [
      RemixSegmentedControlItem(
        value: 'list',
        icon: Icons.view_list,
        semanticLabel: 'List view',
      ),
      RemixSegmentedControlItem(
        value: 'grid',
        icon: Icons.grid_view,
        semanticLabel: 'Grid view',
      ),
    ],
    selectedValue: value,
    onChanged: onChanged,
    style: style,
  );
}

Vertical orientation

Set orientation: Axis.vertical to equalize item heights and use Up/Down arrow navigation. Orientation and per-item disabled state are intentional Flutter part of the Fortal composition model.

Keyboard behavior

KeyBehavior
TabEnters on the selected or first enabled segment; the next Tab exits
Arrow Left / Arrow RightMoves horizontal focus, following LTR/RTL direction
Arrow Up / Arrow DownMoves vertical focus
Home / EndMoves to the first / last enabled segment
Space / EnterSelects the focused inactive segment

Arrow movement changes focus only. loop: true wraps at the ends; set it to false to clamp. A null onChanged or enabled: false disables the track and all items.

Horizontal visual order, keyboard navigation, and semantics all follow the nearest Directionality. Track styling cannot override that direction; use mainAxisSize and spacing for track layout customization.

Sizing and wrapping

The default track is intrinsic width (or intrinsic height vertically). Every item receives the largest item's main-axis extent, and each segment centers its icon and label inside that extent; set an alignment on the item container style to override. Explicit track constraints divide the requested extent equally. In narrow parents, labels wrap inside equal segments; keep labels short and use a visible output label when the selection needs more explanation.

Styling anatomy

SegmentedControlStyler owns the persistent track. Its nested item style is the default for every segment, and each RemixSegmentedControlItem.style merges after it. Put selected, disabled, hover, focus, and press variants on SegmentedControlItemStyler. containerEffects supports inset/shadow stacks and offset focus outlines without changing geometry. A raw styleSpec is authoritative and bypasses fluent group and per-item styles.

Both stylers start empty. Nothing supplies a track background, segment padding, selected surface, or focus ring unless you do, so treat style as required rather than optional.

Fortal presentation

Fortal owns the settled track, item metrics, typography, selected inset fill, crisp frame, shared outside focus ring, and disabled state. Selected surfaces switch in place; labels change typography without duplicate-label crossfades. Selection, keyboard traversal, and semantics remain owned by Remix/Naked UI.

View page source on GitHub