diff --git a/docs.json b/docs.json
index d3fd6a2d..22b5ea24 100644
--- a/docs.json
+++ b/docs.json
@@ -141,6 +141,10 @@
"title": "Radio",
"href": "/components/radio"
},
+ {
+ "title": "Segmented Control",
+ "href": "/components/segmented_control"
+ },
{
"title": "Select",
"href": "/components/select"
diff --git a/docs/components/segmented_control.mdx b/docs/components/segmented_control.mdx
new file mode 100644
index 00000000..3443db73
--- /dev/null
+++ b/docs/components/segmented_control.mdx
@@ -0,0 +1,211 @@
+---
+title: Segmented Control
+description: An equal-segment, controlled single-select control with roving keyboard focus
+keywords: [flutter, remix, segmented control, single select, roving focus, keyboard]
+---
+
+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.
+
+
+ No Fortal preset ships for this component yet, so there is no `FortalSegmentedControl` and no themed default. An unstyled `RemixSegmentedControl` renders as bare text with no track, selected surface, or focus ring. Every example below passes an explicit style, and your app must do the same.
+
+
+## Basic implementation
+
+
+```dart
+import 'package:flutter/material.dart';
+import 'package:remix/remix.dart';
+
+class ReportingPeriodControl extends StatefulWidget {
+ const ReportingPeriodControl({super.key});
+
+ @override
+ State createState() =>
+ _ReportingPeriodControlState();
+}
+
+class _ReportingPeriodControlState extends State {
+ 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()
+ .paddingX(12)
+ .paddingY(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,
+ ),
+ ],
+ ),
+ ),
+ ),
+ )
+ .onFocused(
+ .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(
+ 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()
+ .paddingAll(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.
+
+
+```dart
+import 'package:flutter/material.dart';
+import 'package:remix/remix.dart';
+
+Widget layoutSelector({
+ required String value,
+ required ValueChanged onChanged,
+ // Required, not optional: there is no themed preset to fall back on.
+ required SegmentedControlStyler style,
+}) {
+ return RemixSegmentedControl(
+ 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. 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.
+
+## v1 visual scope
+
+No Fortal preset ships for this component in v1. There is no
+`FortalSegmentedControl` widget and no `fortalSegmentedControlStyle` recipe, so
+a Fortal-themed application must hand the control a `SegmentedControlStyler`
+built from `FortalTokens` (or from its own design tokens). A Radix-parity preset
+is a follow-up, not a hidden default: callers own the visual layer today.
+
+The selected surface is static in v1. A sliding indicator and separator
+treatment are intentionally not reserved or animated; those remain parity
+follow-ups rather than hidden layout layers in this component.
diff --git a/packages/playground/lib/registry/component_registry.dart b/packages/playground/lib/registry/component_registry.dart
index bf7b218d..f6c222e8 100644
--- a/packages/playground/lib/registry/component_registry.dart
+++ b/packages/playground/lib/registry/component_registry.dart
@@ -15,6 +15,7 @@ import 'entries/divider_entry.dart';
import 'entries/menu_entry.dart';
import 'entries/progress_entry.dart';
import 'entries/radio_entry.dart';
+import 'entries/segmented_control_entry.dart';
import 'entries/select_entry.dart';
import 'entries/skeleton_entry.dart';
import 'entries/slider_entry.dart';
@@ -54,6 +55,10 @@ final Map components = {
brightness: Theme.of(context).brightness,
child: PreviewShell(child: buildSelectExample()),
),
+ 'segmented-control': (context) => FortalScope(
+ brightness: Theme.of(context).brightness,
+ child: PreviewShell(child: buildSegmentedControlExample()),
+ ),
'switch': (context) => FortalScope(
brightness: Theme.of(context).brightness,
child: PreviewShell(child: buildSwitchExample()),
diff --git a/packages/playground/lib/registry/entries/segmented_control_entry.dart b/packages/playground/lib/registry/entries/segmented_control_entry.dart
new file mode 100644
index 00000000..41c07e69
--- /dev/null
+++ b/packages/playground/lib/registry/entries/segmented_control_entry.dart
@@ -0,0 +1,155 @@
+import 'package:flutter/material.dart';
+import 'package:remix/remix.dart';
+
+Widget buildSegmentedControlExample() {
+ return const SizedBox(width: 340, child: _RemixSegmentedControlPreview());
+}
+
+class _RemixSegmentedControlPreview extends StatefulWidget {
+ const _RemixSegmentedControlPreview();
+
+ @override
+ State<_RemixSegmentedControlPreview> createState() =>
+ _RemixSegmentedControlPreviewState();
+}
+
+class _RemixSegmentedControlPreviewState
+ extends State<_RemixSegmentedControlPreview> {
+ String _period = 'week';
+ String _view = 'grid';
+ String _density = 'comfortable';
+
+ // Deliberate: this preview hand-rolls a style instead of reaching for a
+ // themed preset. No Fortal recipe ships for the segmented control in v1, so
+ // an unstyled RemixSegmentedControl would render as bare text. Replace this
+ // with the Fortal preset once one exists.
+ SegmentedControlStyler _style(BuildContext context) {
+ final colors = Theme.of(context).colorScheme;
+ final disabledForeground = colors.onSurface.withValues(alpha: 0.35);
+ final selectedShadow = RemixBoxEffectsMix(
+ behindContent: RemixBoxEffectLayerMix(
+ shadows: [
+ RemixBoxShadowMix(
+ color: colors.shadow.withValues(alpha: 0.18),
+ offset: const Offset(0, 1),
+ blurRadius: 3,
+ ),
+ RemixBoxShadowMix(
+ kind: RemixBoxShadowKind.inset,
+ color: colors.outlineVariant,
+ spreadRadius: 1,
+ ),
+ ],
+ ),
+ );
+
+ return SegmentedControlStyler()
+ .paddingAll(4)
+ .borderRadius(BorderRadiusGeometryMix.circular(10))
+ .color(colors.surfaceContainerHighest)
+ .item(
+ SegmentedControlItemStyler()
+ .paddingX(12)
+ .paddingY(8)
+ .spacing(6)
+ .borderRadius(BorderRadiusGeometryMix.circular(7))
+ .labelColor(colors.onSurfaceVariant)
+ .iconColor(colors.onSurfaceVariant)
+ .onSelected(
+ .color(colors.surface)
+ .labelColor(colors.onSurface)
+ .iconColor(colors.onSurface)
+ .containerEffects(selectedShadow),
+ )
+ .onHovered(.color(colors.onSurface.withValues(alpha: 0.06)))
+ .onPressed(.color(colors.onSurface.withValues(alpha: 0.1)))
+ .onFocused(
+ .containerEffects(
+ RemixBoxEffectsMix(
+ outline: BorderSideMix(
+ color: colors.primary,
+ width: 2,
+ strokeAlign: BorderSide.strokeAlignInside,
+ ),
+ outlineOffset: 2,
+ ),
+ ),
+ )
+ .onDisabled(
+ .label(
+ TextStyler().color(disabledForeground),
+ ).iconColor(disabledForeground),
+ ),
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final style = _style(context);
+
+ return Column(
+ mainAxisSize: .min,
+ crossAxisAlignment: .start,
+ children: [
+ RemixSegmentedControl(
+ 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: _period,
+ onChanged: (value) => setState(() => _period = value),
+ style: style,
+ ),
+ const SizedBox(height: 8),
+ Text('Selected: $_period'),
+ const SizedBox(height: 20),
+ RemixSegmentedControl(
+ semanticLabel: 'Layout',
+ items: const [
+ RemixSegmentedControlItem(
+ value: 'list',
+ icon: Icons.view_list,
+ semanticLabel: 'List view',
+ ),
+ RemixSegmentedControlItem(
+ value: 'grid',
+ icon: Icons.grid_view,
+ semanticLabel: 'Grid view',
+ ),
+ RemixSegmentedControlItem(
+ value: 'board',
+ icon: Icons.view_kanban,
+ semanticLabel: 'Board view',
+ ),
+ ],
+ selectedValue: _view,
+ onChanged: (value) => setState(() => _view = value),
+ style: style,
+ ),
+ const SizedBox(height: 20),
+ RemixSegmentedControl(
+ semanticLabel: 'Density',
+ orientation: Axis.vertical,
+ items: const [
+ RemixSegmentedControlItem(value: 'compact', label: 'Compact'),
+ RemixSegmentedControlItem(
+ value: 'comfortable',
+ label: 'Comfortable',
+ ),
+ RemixSegmentedControlItem(value: 'spacious', label: 'Spacious'),
+ ],
+ selectedValue: _density,
+ onChanged: (value) => setState(() => _density = value),
+ style: style,
+ ),
+ ],
+ );
+ }
+}
diff --git a/packages/playground/lib/routes/all_components.dart b/packages/playground/lib/routes/all_components.dart
index 4ef32d01..0f2c0e4b 100644
--- a/packages/playground/lib/routes/all_components.dart
+++ b/packages/playground/lib/routes/all_components.dart
@@ -10,6 +10,7 @@ import '../registry/entries/data_list_entry.dart';
import '../registry/entries/divider_entry.dart';
import '../registry/entries/progress_entry.dart';
import '../registry/entries/radio_entry.dart';
+import '../registry/entries/segmented_control_entry.dart';
import '../registry/entries/select_entry.dart';
import '../registry/entries/slider_entry.dart';
import '../registry/entries/spinner_entry.dart';
@@ -52,6 +53,7 @@ class AllComponentsPage extends StatelessWidget {
_section('Divider', buildDividerExample()),
_section('Progress', buildProgressExample()),
_section('Radio', buildRadioExample()),
+ _section('Segmented Control', buildSegmentedControlExample()),
_section('Select', buildSelectExample()),
_section('Slider', buildSliderExample()),
_section('Spinner', buildSpinnerExample()),
diff --git a/packages/remix/CHANGELOG.md b/packages/remix/CHANGELOG.md
index 04b7fc98..cb4b6ad9 100644
--- a/packages/remix/CHANGELOG.md
+++ b/packages/remix/CHANGELOG.md
@@ -1,5 +1,16 @@
## Unreleased
+- **FEAT**: Add `RemixSegmentedControl`, an equal-segment single-select control
+ mapped from Radix Segmented Control. A custom render object sizes every
+ segment to the largest one and divides an explicit track extent equally,
+ reporting intrinsics that stay consistent with layout so intrinsic-sizing
+ parents wrap labels instead of overflowing. Selection is controlled and
+ never cleared by reactivating the selected segment; `T extends Object` keeps
+ `null` reserved as the no-selection sentinel and `onChanged` non-null.
+ Roving keyboard focus, `Home`/`End`, optional looping, RTL-aware arrow
+ navigation, per-item disabled state, and vertical orientation are supported,
+ and each segment exposes one merged selected-button semantics node. No
+ Fortal preset ships in v1, so callers own the visual layer.
- **FEAT**: Add `RemixTextArea`, a constructor-only multiline facade over
`RemixTextField` with two-line auto-growing defaults and the canonical
`TextFieldStyler` / `TextFieldSpec` styling surface.
diff --git a/packages/remix/lib/remix.dart b/packages/remix/lib/remix.dart
index d82fcad9..27a43f77 100644
--- a/packages/remix/lib/remix.dart
+++ b/packages/remix/lib/remix.dart
@@ -17,6 +17,7 @@ export 'src/components/menu/menu.dart';
export 'src/components/popover/popover.dart';
export 'src/components/progress/progress.dart';
export 'src/components/radio/radio.dart';
+export 'src/components/segmented_control/segmented_control.dart';
export 'src/components/select/select.dart';
export 'src/components/skeleton/skeleton.dart';
export 'src/components/slider/slider.dart';
diff --git a/packages/remix/lib/src/components/segmented_control/segmented_control.dart b/packages/remix/lib/src/components/segmented_control/segmented_control.dart
new file mode 100644
index 00000000..d6f552ca
--- /dev/null
+++ b/packages/remix/lib/src/components/segmented_control/segmented_control.dart
@@ -0,0 +1,19 @@
+library remix_segmented_control;
+
+import 'dart:math' as math;
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+import 'package:mix/mix.dart';
+import 'package:mix_annotations/mix_annotations.dart';
+import 'package:naked_ui/naked_ui.dart';
+
+import '../../rendering/remix_box_effects.dart';
+import '../../style/style.dart';
+import '../../utilities/remix_style.dart';
+
+part 'segmented_control_spec.dart';
+part 'segmented_control_style.dart';
+part 'segmented_control_widget.dart';
+part 'segmented_control.g.dart';
diff --git a/packages/remix/lib/src/components/segmented_control/segmented_control.g.dart b/packages/remix/lib/src/components/segmented_control/segmented_control.g.dart
new file mode 100644
index 00000000..600ff004
--- /dev/null
+++ b/packages/remix/lib/src/components/segmented_control/segmented_control.g.dart
@@ -0,0 +1,1488 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'segmented_control.dart';
+
+// **************************************************************************
+// SpecGenerator
+// **************************************************************************
+
+mixin _$SegmentedControlSpec
+ implements Spec, Diagnosticable {
+ StyleSpec get container;
+ MainAxisSize? get mainAxisSize;
+ double? get spacing;
+ StyleSpec get item;
+
+ @override
+ Type get type => SegmentedControlSpec;
+
+ @override
+ SegmentedControlSpec copyWith({
+ StyleSpec? container,
+ MainAxisSize? mainAxisSize,
+ double? spacing,
+ StyleSpec? item,
+ }) {
+ return SegmentedControlSpec(
+ container: container ?? this.container,
+ mainAxisSize: mainAxisSize ?? this.mainAxisSize,
+ spacing: spacing ?? this.spacing,
+ item: item ?? this.item,
+ );
+ }
+
+ @override
+ SegmentedControlSpec lerp(SegmentedControlSpec? other, double t) {
+ return SegmentedControlSpec(
+ container: container.lerp(other?.container, t),
+ mainAxisSize: MixOps.lerpSnap(mainAxisSize, other?.mainAxisSize, t),
+ spacing: MixOps.lerp(spacing, other?.spacing, t),
+ item: item.lerp(other?.item, t),
+ );
+ }
+
+ @override
+ List