← Back to Blog

Flutter ChoiceChip Localization: Single-Select Options for Multilingual Apps

flutterchoicechipchipselectionlocalizationrtl

Flutter ChoiceChip Localization: Single-Select Options for Multilingual Apps

ChoiceChip is a Flutter Material widget that represents a single selection from a group of options. In multilingual applications, ChoiceChip is essential for presenting translated option labels in a compact single-select group, handling variable chip widths for translations of different lengths, supporting RTL chip flow that wraps correctly from right to left, and providing accessible selection announcements in the active language.

Understanding ChoiceChip in Localization Context

ChoiceChip renders a Material Design chip that indicates selection with a checkmark and elevated styling. Only one ChoiceChip in a group should be selected at a time. For multilingual apps, this enables:

  • Translated option labels in a single-select chip group
  • Compact layout that wraps to fit available width with any translation
  • RTL-aware chip flow using Wrap that reverses automatically
  • Clear visual selection feedback regardless of language

Why ChoiceChip Matters for Multilingual Apps

ChoiceChip provides:

  • Single-select: Exactly one translated option is active at a time
  • Visual distinction: Selected chip uses a distinct color and checkmark
  • Compact options: Multiple translated choices in less space than radio buttons
  • Accessibility: Selection state announced in the active language

Basic ChoiceChip Implementation

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

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

  @override
  State<LocalizedChoiceChipExample> createState() =>
      _LocalizedChoiceChipExampleState();
}

class _LocalizedChoiceChipExampleState
    extends State<LocalizedChoiceChipExample> {
  String _selectedSize = 'medium';

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

    final sizes = {
      'small': l10n.smallLabel,
      'medium': l10n.mediumLabel,
      'large': l10n.largeLabel,
      'extra_large': l10n.extraLargeLabel,
    };

    return Scaffold(
      appBar: AppBar(title: Text(l10n.selectSizeTitle)),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              l10n.sizeLabel,
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 8),
            Wrap(
              spacing: 8,
              runSpacing: 4,
              children: sizes.entries.map((entry) {
                return ChoiceChip(
                  label: Text(entry.value),
                  selected: _selectedSize == entry.key,
                  onSelected: (selected) {
                    if (selected) {
                      setState(() => _selectedSize = entry.key);
                    }
                  },
                );
              }).toList(),
            ),
            const SizedBox(height: 16),
            Text(
              l10n.selectedSizeMessage(sizes[_selectedSize]!),
              style: Theme.of(context).textTheme.bodyLarge,
            ),
          ],
        ),
      ),
    );
  }
}

Advanced ChoiceChip Patterns for Localization

Category Selector with Icons

ChoiceChips with leading icons for visual context alongside translated category names.

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

  @override
  State<CategoryChoiceChips> createState() => _CategoryChoiceChipsState();
}

class _CategoryChoiceChipsState extends State<CategoryChoiceChips> {
  String _selectedCategory = 'all';

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

    final categories = [
      _Category('all', l10n.allCategoriesLabel, Icons.apps),
      _Category('food', l10n.foodCategoryLabel, Icons.restaurant),
      _Category('drinks', l10n.drinksCategoryLabel, Icons.local_cafe),
      _Category('desserts', l10n.dessertsCategoryLabel, Icons.cake),
      _Category('snacks', l10n.snacksCategoryLabel, Icons.fastfood),
    ];

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Padding(
          padding: const EdgeInsetsDirectional.fromSTEB(16, 16, 16, 8),
          child: Text(
            l10n.menuCategoriesLabel,
            style: Theme.of(context).textTheme.titleSmall,
          ),
        ),
        SingleChildScrollView(
          scrollDirection: Axis.horizontal,
          padding: const EdgeInsetsDirectional.only(start: 16),
          child: Row(
            children: categories.map((category) {
              return Padding(
                padding: const EdgeInsetsDirectional.only(end: 8),
                child: ChoiceChip(
                  avatar: Icon(category.icon, size: 18),
                  label: Text(category.label),
                  selected: _selectedCategory == category.key,
                  onSelected: (selected) {
                    if (selected) {
                      setState(() => _selectedCategory = category.key);
                    }
                  },
                ),
              );
            }).toList(),
          ),
        ),
      ],
    );
  }
}

class _Category {
  final String key;
  final String label;
  final IconData icon;

  _Category(this.key, this.label, this.icon);
}

Sort Order Selector

ChoiceChips for selecting a sort order with translated sort options.

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

  @override
  State<SortOrderChoiceChips> createState() => _SortOrderChoiceChipsState();
}

class _SortOrderChoiceChipsState extends State<SortOrderChoiceChips> {
  String _selectedSort = 'relevance';

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

    final sortOptions = {
      'relevance': l10n.sortByRelevanceLabel,
      'newest': l10n.sortByNewestLabel,
      'price_low': l10n.sortByPriceLowLabel,
      'price_high': l10n.sortByPriceHighLabel,
      'rating': l10n.sortByRatingLabel,
    };

    return Padding(
      padding: const EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            l10n.sortByLabel,
            style: Theme.of(context).textTheme.titleSmall,
          ),
          const SizedBox(height: 8),
          Wrap(
            spacing: 8,
            runSpacing: 4,
            children: sortOptions.entries.map((entry) {
              return ChoiceChip(
                label: Text(entry.value),
                selected: _selectedSort == entry.key,
                onSelected: (selected) {
                  if (selected) {
                    setState(() => _selectedSort = entry.key);
                  }
                },
              );
            }).toList(),
          ),
        ],
      ),
    );
  }
}

Time Period Selector

ChoiceChips for selecting a time period with translated duration labels.

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

  @override
  State<TimePeriodChoiceChips> createState() => _TimePeriodChoiceChipsState();
}

class _TimePeriodChoiceChipsState extends State<TimePeriodChoiceChips> {
  String _selectedPeriod = '7d';

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

    final periods = {
      '24h': l10n.last24HoursLabel,
      '7d': l10n.last7DaysLabel,
      '30d': l10n.last30DaysLabel,
      '90d': l10n.last90DaysLabel,
      '1y': l10n.lastYearLabel,
      'all': l10n.allTimeLabel,
    };

    return Card(
      margin: const EdgeInsets.all(16),
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Text(
                  l10n.timePeriodLabel,
                  style: Theme.of(context).textTheme.titleMedium,
                ),
                Icon(
                  Icons.calendar_today,
                  color: Theme.of(context).colorScheme.onSurfaceVariant,
                  size: 20,
                ),
              ],
            ),
            const SizedBox(height: 12),
            Wrap(
              spacing: 8,
              runSpacing: 4,
              children: periods.entries.map((entry) {
                return ChoiceChip(
                  label: Text(entry.value),
                  selected: _selectedPeriod == entry.key,
                  onSelected: (selected) {
                    if (selected) {
                      setState(() => _selectedPeriod = entry.key);
                    }
                  },
                );
              }).toList(),
            ),
          ],
        ),
      ),
    );
  }
}

RTL Support and Bidirectional Layouts

ChoiceChip works correctly in RTL layouts. When placed inside a Wrap, chips flow from right to left. The checkmark and avatar icons position correctly based on text direction.

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

  @override
  State<BidirectionalChoiceChips> createState() =>
      _BidirectionalChoiceChipsState();
}

class _BidirectionalChoiceChipsState extends State<BidirectionalChoiceChips> {
  String _selected = 'standard';

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

    final options = {
      'express': l10n.expressShippingLabel,
      'standard': l10n.standardShippingLabel,
      'economy': l10n.economyShippingLabel,
    };

    return Padding(
      padding: const EdgeInsetsDirectional.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            l10n.shippingMethodLabel,
            style: Theme.of(context).textTheme.titleSmall,
          ),
          const SizedBox(height: 8),
          Wrap(
            spacing: 8,
            runSpacing: 4,
            children: options.entries.map((entry) {
              return ChoiceChip(
                label: Text(entry.value),
                selected: _selected == entry.key,
                onSelected: (selected) {
                  if (selected) {
                    setState(() => _selected = entry.key);
                  }
                },
              );
            }).toList(),
          ),
        ],
      ),
    );
  }
}

Testing ChoiceChip Localization

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

void main() {
  Widget buildTestWidget({Locale locale = const Locale('en')}) {
    return MaterialApp(
      locale: locale,
      localizationsDelegates: AppLocalizations.localizationsDelegates,
      supportedLocales: AppLocalizations.supportedLocales,
      home: const LocalizedChoiceChipExample(),
    );
  }

  testWidgets('ChoiceChip renders localized labels', (tester) async {
    await tester.pumpWidget(buildTestWidget());
    await tester.pumpAndSettle();
    expect(find.byType(ChoiceChip), findsWidgets);
  });

  testWidgets('ChoiceChip selection changes', (tester) async {
    await tester.pumpWidget(buildTestWidget());
    await tester.pumpAndSettle();
    final chip = find.byType(ChoiceChip).first;
    await tester.tap(chip);
    await tester.pumpAndSettle();
    expect(tester.takeException(), isNull);
  });

  testWidgets('ChoiceChip works in RTL', (tester) async {
    await tester.pumpWidget(buildTestWidget(locale: const Locale('ar')));
    await tester.pumpAndSettle();
    expect(tester.takeException(), isNull);
  });
}

Best Practices

  1. Enforce single selection by only setting selected: true on one ChoiceChip at a time, updating state when any chip is tapped.

  2. Use Wrap for responsive layouts so ChoiceChips flow to the next line when translated labels are wider than the screen.

  3. Provide avatar icons alongside translated labels for visual context that helps users identify options without relying on text alone.

  4. Show the selected value in a translated confirmation message using a parameterized label like selectedSizeMessage(size).

  5. Use horizontal SingleChildScrollView for single-row chip strips (like category tabs) where vertical wrapping isn't appropriate.

  6. Test with verbose languages to verify chips wrap correctly and the selected state is visually clear at all sizes.

Conclusion

ChoiceChip provides a compact single-select option widget for Flutter apps. For multilingual apps, it handles translated option labels with clear visual selection feedback, wraps correctly in RTL layouts, and works naturally with Wrap for responsive chip groups. By combining ChoiceChip with category selectors, sort controls, and time period pickers, you can build compact selection interfaces that communicate clearly in every supported language.

Further Reading