Segmented Control
An equal-segment, controlled single-select control with roving keyboard focus
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 pinned Radix Themes 3.3 track,
sizes, surface/classic variants, typography, selected layers, focus outline,
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>.classic(
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
extensions to the Radix model.
Keyboard behavior
| Key | Behavior |
|---|---|
Tab | Enters on the selected or first enabled segment; the next Tab exits |
Arrow Left / Arrow Right | Moves horizontal focus, following LTR/RTL direction |
Arrow Up / Arrow Down | Moves vertical focus |
Home / End | Moves to the first / last enabled segment |
Space / Enter | Selects 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 parity boundaries
Fortal matches the settled track, item metrics, inactive/active typography, selected inset fill, surface ring or five-layer classic shadow, focus outline, and disabled state. Three Radix presentation details remain explicit v1 approximations:
- The selected surface switches directly on the selected item instead of sliding one shared indicator over 100 ms.
- One label changes typography in place instead of crossfading duplicate inactive and active labels.
- Inactive-item separator pseudo-elements are omitted.
These differences do not change selection, focus, keyboard, semantics, item geometry, or the resolved selected and disabled layers.