← Back to Blog

Flutter SegmentedButton Localization: Material 3 Toggle Groups and Selection Controls

fluttersegmentedbuttonmaterial3localizationtoggleselection

Flutter SegmentedButton Localization: Material 3 Toggle Groups and Selection Controls

SegmentedButton is a Material 3 widget that allows users to select from a set of options. Localizing segmented buttons ensures users worldwide can understand and interact with your selection controls effectively. This guide covers everything you need to know about localizing segmented buttons in Flutter.

Understanding SegmentedButton Localization Needs

Segmented buttons require localization for:

  • Segment labels: Text on each option
  • Icons with labels: Combined icon and text
  • Tooltips: Additional context on hover/long-press
  • Accessibility: Screen reader announcements
  • RTL support: Proper segment ordering
  • Selection state: Announcements for selected/unselected

Basic SegmentedButton with Localization

Start with a properly localized segmented button:

import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

enum ViewMode { list, grid, table }

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

  @override
  State<LocalizedSegmentedButton> createState() =>
      _LocalizedSegmentedButtonState();
}

class _LocalizedSegmentedButtonState extends State<LocalizedSegmentedButton> {
  ViewMode _selectedView = ViewMode.list;

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          l10n.viewModeLabel,
          style: Theme.of(context).textTheme.labelLarge,
        ),
        const SizedBox(height: 8),
        SegmentedButton<ViewMode>(
          segments: [
            ButtonSegment(
              value: ViewMode.list,
              icon: const Icon(Icons.view_list),
              label: Text(l10n.viewModeList),
              tooltip: l10n.viewModeListTooltip,
            ),
            ButtonSegment(
              value: ViewMode.grid,
              icon: const Icon(Icons.grid_view),
              label: Text(l10n.viewModeGrid),
              tooltip: l10n.viewModeGridTooltip,
            ),
            ButtonSegment(
              value: ViewMode.table,
              icon: const Icon(Icons.table_rows),
              label: Text(l10n.viewModeTable),
              tooltip: l10n.viewModeTableTooltip,
            ),
          ],
          selected: {_selectedView},
          onSelectionChanged: (selection) {
            setState(() => _selectedView = selection.first);
          },
        ),
      ],
    );
  }
}

ARB file entries:

{
  "viewModeLabel": "View mode",
  "@viewModeLabel": {
    "description": "Label for view mode selector"
  },
  "viewModeList": "List",
  "@viewModeList": {
    "description": "List view option"
  },
  "viewModeGrid": "Grid",
  "@viewModeGrid": {
    "description": "Grid view option"
  },
  "viewModeTable": "Table",
  "@viewModeTable": {
    "description": "Table view option"
  },
  "viewModeListTooltip": "Display items in a list",
  "viewModeGridTooltip": "Display items in a grid",
  "viewModeTableTooltip": "Display items in a table"
}

Multi-Select Segmented Button

Handle multiple selection with proper localization:

enum DayOfWeek { mon, tue, wed, thu, fri, sat, sun }

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

  @override
  State<LocalizedMultiSelectSegment> createState() =>
      _LocalizedMultiSelectSegmentState();
}

class _LocalizedMultiSelectSegmentState
    extends State<LocalizedMultiSelectSegment> {
  Set<DayOfWeek> _selectedDays = {DayOfWeek.mon, DayOfWeek.wed, DayOfWeek.fri};

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          l10n.repeatDaysLabel,
          style: Theme.of(context).textTheme.labelLarge,
        ),
        const SizedBox(height: 8),
        SegmentedButton<DayOfWeek>(
          multiSelectionEnabled: true,
          emptySelectionAllowed: true,
          segments: [
            ButtonSegment(
              value: DayOfWeek.mon,
              label: Text(_getShortDayName(DayOfWeek.mon, l10n)),
              tooltip: _getFullDayName(DayOfWeek.mon, l10n),
            ),
            ButtonSegment(
              value: DayOfWeek.tue,
              label: Text(_getShortDayName(DayOfWeek.tue, l10n)),
              tooltip: _getFullDayName(DayOfWeek.tue, l10n),
            ),
            ButtonSegment(
              value: DayOfWeek.wed,
              label: Text(_getShortDayName(DayOfWeek.wed, l10n)),
              tooltip: _getFullDayName(DayOfWeek.wed, l10n),
            ),
            ButtonSegment(
              value: DayOfWeek.thu,
              label: Text(_getShortDayName(DayOfWeek.thu, l10n)),
              tooltip: _getFullDayName(DayOfWeek.thu, l10n),
            ),
            ButtonSegment(
              value: DayOfWeek.fri,
              label: Text(_getShortDayName(DayOfWeek.fri, l10n)),
              tooltip: _getFullDayName(DayOfWeek.fri, l10n),
            ),
            ButtonSegment(
              value: DayOfWeek.sat,
              label: Text(_getShortDayName(DayOfWeek.sat, l10n)),
              tooltip: _getFullDayName(DayOfWeek.sat, l10n),
            ),
            ButtonSegment(
              value: DayOfWeek.sun,
              label: Text(_getShortDayName(DayOfWeek.sun, l10n)),
              tooltip: _getFullDayName(DayOfWeek.sun, l10n),
            ),
          ],
          selected: _selectedDays,
          onSelectionChanged: (selection) {
            setState(() => _selectedDays = selection);
          },
        ),
        const SizedBox(height: 8),
        Text(
          _getSelectionSummary(l10n),
          style: Theme.of(context).textTheme.bodySmall,
        ),
      ],
    );
  }

  String _getShortDayName(DayOfWeek day, AppLocalizations l10n) {
    switch (day) {
      case DayOfWeek.mon:
        return l10n.dayMonShort;
      case DayOfWeek.tue:
        return l10n.dayTueShort;
      case DayOfWeek.wed:
        return l10n.dayWedShort;
      case DayOfWeek.thu:
        return l10n.dayThuShort;
      case DayOfWeek.fri:
        return l10n.dayFriShort;
      case DayOfWeek.sat:
        return l10n.daySatShort;
      case DayOfWeek.sun:
        return l10n.daySunShort;
    }
  }

  String _getFullDayName(DayOfWeek day, AppLocalizations l10n) {
    switch (day) {
      case DayOfWeek.mon:
        return l10n.dayMonday;
      case DayOfWeek.tue:
        return l10n.dayTuesday;
      case DayOfWeek.wed:
        return l10n.dayWednesday;
      case DayOfWeek.thu:
        return l10n.dayThursday;
      case DayOfWeek.fri:
        return l10n.dayFriday;
      case DayOfWeek.sat:
        return l10n.daySaturday;
      case DayOfWeek.sun:
        return l10n.daySunday;
    }
  }

  String _getSelectionSummary(AppLocalizations l10n) {
    if (_selectedDays.isEmpty) {
      return l10n.noDaysSelected;
    }

    if (_selectedDays.length == 7) {
      return l10n.everyDay;
    }

    final weekdays = {
      DayOfWeek.mon,
      DayOfWeek.tue,
      DayOfWeek.wed,
      DayOfWeek.thu,
      DayOfWeek.fri,
    };
    final weekends = {DayOfWeek.sat, DayOfWeek.sun};

    if (_selectedDays.containsAll(weekdays) &&
        !_selectedDays.contains(DayOfWeek.sat) &&
        !_selectedDays.contains(DayOfWeek.sun)) {
      return l10n.weekdaysOnly;
    }

    if (_selectedDays.containsAll(weekends) && _selectedDays.length == 2) {
      return l10n.weekendsOnly;
    }

    return l10n.daysSelected(_selectedDays.length);
  }
}

ARB entries:

{
  "repeatDaysLabel": "Repeat on",
  "dayMonShort": "M",
  "dayTueShort": "T",
  "dayWedShort": "W",
  "dayThuShort": "T",
  "dayFriShort": "F",
  "daySatShort": "S",
  "daySunShort": "S",
  "dayMonday": "Monday",
  "dayTuesday": "Tuesday",
  "dayWednesday": "Wednesday",
  "dayThursday": "Thursday",
  "dayFriday": "Friday",
  "daySaturday": "Saturday",
  "daySunday": "Sunday",
  "noDaysSelected": "No days selected",
  "everyDay": "Every day",
  "weekdaysOnly": "Weekdays only",
  "weekendsOnly": "Weekends only",
  "daysSelected": "{count, plural, =1{1 day selected} other{{count} days selected}}",
  "@daysSelected": {
    "placeholders": {
      "count": {"type": "int"}
    }
  }
}

Filter Segments with Dynamic Labels

Create filter controls with count badges:

enum FilterStatus { all, active, completed, archived }

class FilterSegmentedButton extends StatefulWidget {
  final Map<FilterStatus, int> counts;
  final FilterStatus selected;
  final ValueChanged<FilterStatus> onChanged;

  const FilterSegmentedButton({
    super.key,
    required this.counts,
    required this.selected,
    required this.onChanged,
  });

  @override
  State<FilterSegmentedButton> createState() => _FilterSegmentedButtonState();
}

class _FilterSegmentedButtonState extends State<FilterSegmentedButton> {
  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final locale = Localizations.localeOf(context);
    final numberFormat = NumberFormat.compact(locale: locale.toString());

    return SegmentedButton<FilterStatus>(
      segments: [
        ButtonSegment(
          value: FilterStatus.all,
          label: Text(_buildLabel(
            l10n.filterAll,
            widget.counts[FilterStatus.all] ?? 0,
            numberFormat,
          )),
        ),
        ButtonSegment(
          value: FilterStatus.active,
          icon: const Icon(Icons.circle, size: 12, color: Colors.green),
          label: Text(_buildLabel(
            l10n.filterActive,
            widget.counts[FilterStatus.active] ?? 0,
            numberFormat,
          )),
        ),
        ButtonSegment(
          value: FilterStatus.completed,
          icon: const Icon(Icons.check_circle, size: 12),
          label: Text(_buildLabel(
            l10n.filterCompleted,
            widget.counts[FilterStatus.completed] ?? 0,
            numberFormat,
          )),
        ),
        ButtonSegment(
          value: FilterStatus.archived,
          icon: const Icon(Icons.archive, size: 12),
          label: Text(_buildLabel(
            l10n.filterArchived,
            widget.counts[FilterStatus.archived] ?? 0,
            numberFormat,
          )),
        ),
      ],
      selected: {widget.selected},
      onSelectionChanged: (selection) {
        widget.onChanged(selection.first);
      },
    );
  }

  String _buildLabel(String text, int count, NumberFormat format) {
    if (count == 0) return text;
    return '$text (${format.format(count)})';
  }
}

ARB entries:

{
  "filterAll": "All",
  "filterActive": "Active",
  "filterCompleted": "Completed",
  "filterArchived": "Archived"
}

Segmented Button with Icons Only

Handle icon-only segments with accessibility:

enum TextAlignment { left, center, right, justify }

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

  @override
  State<IconOnlySegmentedButton> createState() =>
      _IconOnlySegmentedButtonState();
}

class _IconOnlySegmentedButtonState extends State<IconOnlySegmentedButton> {
  TextAlignment _alignment = TextAlignment.left;

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final isRTL = Directionality.of(context) == TextDirection.rtl;

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          l10n.textAlignmentLabel,
          style: Theme.of(context).textTheme.labelLarge,
        ),
        const SizedBox(height: 8),
        Semantics(
          label: l10n.textAlignmentAccessibilityLabel,
          child: SegmentedButton<TextAlignment>(
            showSelectedIcon: false,
            segments: [
              ButtonSegment(
                value: TextAlignment.left,
                icon: Icon(isRTL ? Icons.format_align_right : Icons.format_align_left),
                tooltip: l10n.alignLeft,
              ),
              ButtonSegment(
                value: TextAlignment.center,
                icon: const Icon(Icons.format_align_center),
                tooltip: l10n.alignCenter,
              ),
              ButtonSegment(
                value: TextAlignment.right,
                icon: Icon(isRTL ? Icons.format_align_left : Icons.format_align_right),
                tooltip: l10n.alignRight,
              ),
              ButtonSegment(
                value: TextAlignment.justify,
                icon: const Icon(Icons.format_align_justify),
                tooltip: l10n.alignJustify,
              ),
            ],
            selected: {_alignment},
            onSelectionChanged: (selection) {
              setState(() => _alignment = selection.first);
              _announceSelection(selection.first, l10n, context);
            },
          ),
        ),
      ],
    );
  }

  void _announceSelection(
    TextAlignment alignment,
    AppLocalizations l10n,
    BuildContext context,
  ) {
    String announcement;
    switch (alignment) {
      case TextAlignment.left:
        announcement = l10n.alignLeftSelected;
        break;
      case TextAlignment.center:
        announcement = l10n.alignCenterSelected;
        break;
      case TextAlignment.right:
        announcement = l10n.alignRightSelected;
        break;
      case TextAlignment.justify:
        announcement = l10n.alignJustifySelected;
        break;
    }

    SemanticsService.announce(announcement, Directionality.of(context));
  }
}

ARB entries:

{
  "textAlignmentLabel": "Text alignment",
  "textAlignmentAccessibilityLabel": "Text alignment options",
  "alignLeft": "Align left",
  "alignCenter": "Center",
  "alignRight": "Align right",
  "alignJustify": "Justify",
  "alignLeftSelected": "Left alignment selected",
  "alignCenterSelected": "Center alignment selected",
  "alignRightSelected": "Right alignment selected",
  "alignJustifySelected": "Justify alignment selected"
}

Responsive Segmented Button

Adapt segment labels for different screen sizes:

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

  @override
  State<ResponsiveSegmentedButton> createState() =>
      _ResponsiveSegmentedButtonState();
}

class _ResponsiveSegmentedButtonState extends State<ResponsiveSegmentedButton> {
  String _selected = 'week';

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final isCompact = MediaQuery.of(context).size.width < 400;

    return SegmentedButton<String>(
      segments: [
        ButtonSegment(
          value: 'day',
          icon: isCompact ? const Icon(Icons.today) : null,
          label: Text(isCompact ? l10n.periodDayShort : l10n.periodDay),
          tooltip: l10n.periodDayTooltip,
        ),
        ButtonSegment(
          value: 'week',
          icon: isCompact ? const Icon(Icons.view_week) : null,
          label: Text(isCompact ? l10n.periodWeekShort : l10n.periodWeek),
          tooltip: l10n.periodWeekTooltip,
        ),
        ButtonSegment(
          value: 'month',
          icon: isCompact ? const Icon(Icons.calendar_month) : null,
          label: Text(isCompact ? l10n.periodMonthShort : l10n.periodMonth),
          tooltip: l10n.periodMonthTooltip,
        ),
        ButtonSegment(
          value: 'year',
          icon: isCompact ? const Icon(Icons.calendar_today) : null,
          label: Text(isCompact ? l10n.periodYearShort : l10n.periodYear),
          tooltip: l10n.periodYearTooltip,
        ),
      ],
      selected: {_selected},
      onSelectionChanged: (selection) {
        setState(() => _selected = selection.first);
      },
    );
  }
}

ARB entries:

{
  "periodDay": "Day",
  "periodDayShort": "D",
  "periodDayTooltip": "View by day",
  "periodWeek": "Week",
  "periodWeekShort": "W",
  "periodWeekTooltip": "View by week",
  "periodMonth": "Month",
  "periodMonthShort": "M",
  "periodMonthTooltip": "View by month",
  "periodYear": "Year",
  "periodYearShort": "Y",
  "periodYearTooltip": "View by year"
}

Segmented Button with Disabled States

Handle disabled segments with proper messaging:

class DisabledSegmentedButton extends StatefulWidget {
  final bool isPremiumUser;

  const DisabledSegmentedButton({
    super.key,
    required this.isPremiumUser,
  });

  @override
  State<DisabledSegmentedButton> createState() =>
      _DisabledSegmentedButtonState();
}

class _DisabledSegmentedButtonState extends State<DisabledSegmentedButton> {
  String _exportFormat = 'pdf';

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          l10n.exportFormatLabel,
          style: Theme.of(context).textTheme.labelLarge,
        ),
        const SizedBox(height: 8),
        SegmentedButton<String>(
          segments: [
            ButtonSegment(
              value: 'pdf',
              icon: const Icon(Icons.picture_as_pdf),
              label: Text(l10n.exportPdf),
            ),
            ButtonSegment(
              value: 'csv',
              icon: const Icon(Icons.table_chart),
              label: Text(l10n.exportCsv),
            ),
            ButtonSegment(
              value: 'excel',
              icon: const Icon(Icons.grid_on),
              label: Text(l10n.exportExcel),
              enabled: widget.isPremiumUser,
            ),
            ButtonSegment(
              value: 'json',
              icon: const Icon(Icons.code),
              label: Text(l10n.exportJson),
              enabled: widget.isPremiumUser,
            ),
          ],
          selected: {_exportFormat},
          onSelectionChanged: (selection) {
            setState(() => _exportFormat = selection.first);
          },
        ),
        if (!widget.isPremiumUser) ...[
          const SizedBox(height: 8),
          Row(
            children: [
              Icon(
                Icons.info_outline,
                size: 16,
                color: Theme.of(context).hintColor,
              ),
              const SizedBox(width: 4),
              Flexible(
                child: Text(
                  l10n.premiumFormatsMessage,
                  style: Theme.of(context).textTheme.bodySmall,
                ),
              ),
            ],
          ),
        ],
      ],
    );
  }
}

ARB entries:

{
  "exportFormatLabel": "Export format",
  "exportPdf": "PDF",
  "exportCsv": "CSV",
  "exportExcel": "Excel",
  "exportJson": "JSON",
  "premiumFormatsMessage": "Excel and JSON formats require a premium subscription"
}

Testing Segmented Button Localization

Write comprehensive tests for segmented button behavior:

void main() {
  group('LocalizedSegmentedButton Tests', () {
    testWidgets('displays localized labels', (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          localizationsDelegates: const [
            AppLocalizations.delegate,
            GlobalMaterialLocalizations.delegate,
            GlobalWidgetsLocalizations.delegate,
          ],
          locale: const Locale('en'),
          home: const Scaffold(
            body: LocalizedSegmentedButton(),
          ),
        ),
      );

      expect(find.text('List'), findsOneWidget);
      expect(find.text('Grid'), findsOneWidget);
      expect(find.text('Table'), findsOneWidget);
    });

    testWidgets('shows German labels', (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          localizationsDelegates: const [
            AppLocalizations.delegate,
            GlobalMaterialLocalizations.delegate,
            GlobalWidgetsLocalizations.delegate,
          ],
          locale: const Locale('de'),
          home: const Scaffold(
            body: LocalizedSegmentedButton(),
          ),
        ),
      );

      expect(find.text('Liste'), findsOneWidget);
      expect(find.text('Raster'), findsOneWidget);
      expect(find.text('Tabelle'), findsOneWidget);
    });

    testWidgets('handles RTL layout correctly', (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          localizationsDelegates: const [
            AppLocalizations.delegate,
            GlobalMaterialLocalizations.delegate,
            GlobalWidgetsLocalizations.delegate,
          ],
          locale: const Locale('ar'),
          home: const Directionality(
            textDirection: TextDirection.rtl,
            child: Scaffold(
              body: IconOnlySegmentedButton(),
            ),
          ),
        ),
      );

      // Verify RTL icons are mirrored
      expect(find.byIcon(Icons.format_align_right), findsOneWidget);
    });

    testWidgets('announces selection to screen readers', (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          localizationsDelegates: const [
            AppLocalizations.delegate,
            GlobalMaterialLocalizations.delegate,
            GlobalWidgetsLocalizations.delegate,
          ],
          locale: const Locale('en'),
          home: const Scaffold(
            body: LocalizedSegmentedButton(),
          ),
        ),
      );

      // Tap on Grid segment
      await tester.tap(find.text('Grid'));
      await tester.pumpAndSettle();

      // Verify selection changed
      final segmentedButton = tester.widget<SegmentedButton<ViewMode>>(
        find.byType(SegmentedButton<ViewMode>),
      );
      expect(segmentedButton.selected, {ViewMode.grid});
    });
  });
}

Best Practices Summary

  1. Localize all labels: Segment text, tooltips, accessibility labels
  2. Handle RTL: Mirror directional icons and layouts
  3. Use short labels: Abbreviate on small screens
  4. Include tooltips: Provide context for icon-only segments
  5. Announce changes: Screen reader accessibility
  6. Show disabled reasons: Explain why options are unavailable
  7. Format counts: Use locale-appropriate number formatting

Conclusion

Localizing segmented buttons in Flutter requires attention to labels, tooltips, accessibility, and RTL support. By implementing proper localized text, responsive labels, and accessible announcements, you ensure users worldwide can effectively interact with your selection controls.