← Back to Blog

Flutter Chip Localization: Filter, Choice, Input, and Action Chips

flutterchipfilterlocalizationselectionactions

Flutter Chip Localization: Filter, Choice, Input, and Action Chips

Chips are compact elements that represent inputs, attributes, or actions in Flutter applications. From filter tags to category selectors, properly localizing chip content ensures your UI elements are accessible and meaningful to users worldwide. This guide covers all chip variants and their localization requirements.

Understanding Chip Types

Flutter provides several chip variants, each with specific localization needs:

  • Chip: Basic chip with optional delete button
  • InputChip: Represents complex information with optional avatar
  • ChoiceChip: Single selection from a set
  • FilterChip: Multiple selection filters
  • ActionChip: Triggers an action

Basic Chip Localization

Let's start with simple localized chips:

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

class LocalizedChipExample extends StatelessWidget {
  final List<String> selectedTags;
  final ValueChanged<String> onTagRemoved;

  const LocalizedChipExample({
    super.key,
    required this.selectedTags,
    required this.onTagRemoved,
  });

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

    return Wrap(
      spacing: 8,
      runSpacing: 4,
      children: selectedTags.map((tag) {
        return Chip(
          label: Text(_getLocalizedTag(tag, l10n)),
          deleteIcon: const Icon(Icons.close, size: 18),
          deleteButtonTooltipMessage: l10n.removeTag,
          onDeleted: () => onTagRemoved(tag),
        );
      }).toList(),
    );
  }

  String _getLocalizedTag(String tag, AppLocalizations l10n) {
    return switch (tag) {
      'new' => l10n.tagNew,
      'popular' => l10n.tagPopular,
      'sale' => l10n.tagSale,
      'featured' => l10n.tagFeatured,
      _ => tag,
    };
  }
}

ARB Files for Chip Localization

English (app_en.arb)

{
  "@@locale": "en",

  "tagNew": "New",
  "@tagNew": {
    "description": "Tag for new items"
  },

  "tagPopular": "Popular",
  "@tagPopular": {
    "description": "Tag for popular items"
  },

  "tagSale": "Sale",
  "@tagSale": {
    "description": "Tag for items on sale"
  },

  "tagFeatured": "Featured",
  "@tagFeatured": {
    "description": "Tag for featured items"
  },

  "removeTag": "Remove tag",
  "@removeTag": {
    "description": "Tooltip for remove tag button"
  },

  "addTag": "Add tag",
  "@addTag": {
    "description": "Tooltip for add tag button"
  },

  "categoryAll": "All",
  "@categoryAll": {
    "description": "All categories filter"
  },

  "categoryElectronics": "Electronics",
  "@categoryElectronics": {
    "description": "Electronics category"
  },

  "categoryClothing": "Clothing",
  "@categoryClothing": {
    "description": "Clothing category"
  },

  "categoryBooks": "Books",
  "@categoryBooks": {
    "description": "Books category"
  },

  "categoryFood": "Food",
  "@categoryFood": {
    "description": "Food category"
  },

  "categorySports": "Sports",
  "@categorySports": {
    "description": "Sports category"
  },

  "filterBy": "Filter by",
  "@filterBy": {
    "description": "Label for filter section"
  },

  "filtersApplied": "{count, plural, =0{No filters} =1{1 filter} other{{count} filters}}",
  "@filtersApplied": {
    "description": "Number of filters applied",
    "placeholders": {
      "count": {"type": "int"}
    }
  },

  "clearFilters": "Clear all",
  "@clearFilters": {
    "description": "Button to clear all filters"
  },

  "priceRangeLow": "Under $50",
  "@priceRangeLow": {
    "description": "Low price range filter"
  },

  "priceRangeMedium": "$50 - $100",
  "@priceRangeMedium": {
    "description": "Medium price range filter"
  },

  "priceRangeHigh": "Over $100",
  "@priceRangeHigh": {
    "description": "High price range filter"
  },

  "sizeSmall": "Small",
  "@sizeSmall": {
    "description": "Small size option"
  },

  "sizeMedium": "Medium",
  "@sizeMedium": {
    "description": "Medium size option"
  },

  "sizeLarge": "Large",
  "@sizeLarge": {
    "description": "Large size option"
  },

  "sizeExtraLarge": "Extra Large",
  "@sizeExtraLarge": {
    "description": "Extra large size option"
  },

  "colorRed": "Red",
  "@colorRed": {
    "description": "Red color option"
  },

  "colorBlue": "Blue",
  "@colorBlue": {
    "description": "Blue color option"
  },

  "colorGreen": "Green",
  "@colorGreen": {
    "description": "Green color option"
  },

  "colorBlack": "Black",
  "@colorBlack": {
    "description": "Black color option"
  },

  "colorWhite": "White",
  "@colorWhite": {
    "description": "White color option"
  },

  "ratingStars": "{count, plural, =1{1 star & up} other{{count} stars & up}}",
  "@ratingStars": {
    "description": "Rating filter label",
    "placeholders": {
      "count": {"type": "int"}
    }
  },

  "inStock": "In Stock",
  "@inStock": {
    "description": "In stock filter"
  },

  "freeShipping": "Free Shipping",
  "@freeShipping": {
    "description": "Free shipping filter"
  },

  "onSale": "On Sale",
  "@onSale": {
    "description": "On sale filter"
  }
}

Spanish (app_es.arb)

{
  "@@locale": "es",

  "tagNew": "Nuevo",
  "tagPopular": "Popular",
  "tagSale": "Oferta",
  "tagFeatured": "Destacado",
  "removeTag": "Eliminar etiqueta",
  "addTag": "Agregar etiqueta",

  "categoryAll": "Todos",
  "categoryElectronics": "Electronica",
  "categoryClothing": "Ropa",
  "categoryBooks": "Libros",
  "categoryFood": "Comida",
  "categorySports": "Deportes",

  "filterBy": "Filtrar por",
  "filtersApplied": "{count, plural, =0{Sin filtros} =1{1 filtro} other{{count} filtros}}",
  "clearFilters": "Limpiar todo",

  "priceRangeLow": "Menos de $50",
  "priceRangeMedium": "$50 - $100",
  "priceRangeHigh": "Mas de $100",

  "sizeSmall": "Pequeno",
  "sizeMedium": "Mediano",
  "sizeLarge": "Grande",
  "sizeExtraLarge": "Extra Grande",

  "colorRed": "Rojo",
  "colorBlue": "Azul",
  "colorGreen": "Verde",
  "colorBlack": "Negro",
  "colorWhite": "Blanco",

  "ratingStars": "{count, plural, =1{1 estrella o mas} other{{count} estrellas o mas}}",
  "inStock": "En Stock",
  "freeShipping": "Envio Gratis",
  "onSale": "En Oferta"
}

Arabic (app_ar.arb)

{
  "@@locale": "ar",

  "tagNew": "جديد",
  "tagPopular": "شائع",
  "tagSale": "تخفيض",
  "tagFeatured": "مميز",
  "removeTag": "إزالة الوسم",
  "addTag": "إضافة وسم",

  "categoryAll": "الكل",
  "categoryElectronics": "إلكترونيات",
  "categoryClothing": "ملابس",
  "categoryBooks": "كتب",
  "categoryFood": "طعام",
  "categorySports": "رياضة",

  "filterBy": "تصفية حسب",
  "filtersApplied": "{count, plural, =0{بدون فلاتر} =1{فلتر واحد} two{فلتران} few{{count} فلاتر} many{{count} فلتراً} other{{count} فلتر}}",
  "clearFilters": "مسح الكل",

  "priceRangeLow": "أقل من ٥٠ ر.س",
  "priceRangeMedium": "٥٠ - ١٠٠ ر.س",
  "priceRangeHigh": "أكثر من ١٠٠ ر.س",

  "sizeSmall": "صغير",
  "sizeMedium": "متوسط",
  "sizeLarge": "كبير",
  "sizeExtraLarge": "كبير جداً",

  "colorRed": "أحمر",
  "colorBlue": "أزرق",
  "colorGreen": "أخضر",
  "colorBlack": "أسود",
  "colorWhite": "أبيض",

  "ratingStars": "{count, plural, =1{نجمة واحدة فأكثر} two{نجمتان فأكثر} few{{count} نجوم فأكثر} many{{count} نجمة فأكثر} other{{count} نجمة فأكثر}}",
  "inStock": "متوفر",
  "freeShipping": "شحن مجاني",
  "onSale": "عرض خاص"
}

FilterChip with Localization

class LocalizedFilterChips extends StatefulWidget {
  final Function(Set<String>) onFiltersChanged;

  const LocalizedFilterChips({super.key, required this.onFiltersChanged});

  @override
  State<LocalizedFilterChips> createState() => _LocalizedFilterChipsState();
}

class _LocalizedFilterChipsState extends State<LocalizedFilterChips> {
  final Set<String> _selectedFilters = {};

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

    final filters = [
      FilterOption(id: 'inStock', label: l10n.inStock, icon: Icons.inventory),
      FilterOption(id: 'freeShipping', label: l10n.freeShipping, icon: Icons.local_shipping),
      FilterOption(id: 'onSale', label: l10n.onSale, icon: Icons.sell),
    ];

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        // Header with filter count
        Row(
          children: [
            Text(
              l10n.filterBy,
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(width: 8),
            if (_selectedFilters.isNotEmpty)
              Chip(
                label: Text(l10n.filtersApplied(_selectedFilters.length)),
                deleteIcon: const Icon(Icons.close, size: 16),
                onDeleted: () {
                  setState(() => _selectedFilters.clear());
                  widget.onFiltersChanged(_selectedFilters);
                },
              ),
          ],
        ),
        const SizedBox(height: 12),

        // Filter chips
        Wrap(
          spacing: 8,
          runSpacing: 8,
          children: filters.map((filter) {
            final isSelected = _selectedFilters.contains(filter.id);

            return FilterChip(
              avatar: Icon(
                filter.icon,
                size: 18,
                color: isSelected
                    ? Theme.of(context).colorScheme.onSecondaryContainer
                    : Theme.of(context).colorScheme.onSurfaceVariant,
              ),
              label: Text(filter.label),
              selected: isSelected,
              onSelected: (selected) {
                setState(() {
                  if (selected) {
                    _selectedFilters.add(filter.id);
                  } else {
                    _selectedFilters.remove(filter.id);
                  }
                });
                widget.onFiltersChanged(_selectedFilters);
              },
            );
          }).toList(),
        ),
      ],
    );
  }
}

class FilterOption {
  final String id;
  final String label;
  final IconData icon;

  FilterOption({required this.id, required this.label, required this.icon});
}

ChoiceChip for Single Selection

class LocalizedChoiceChips extends StatefulWidget {
  final String? selectedCategory;
  final ValueChanged<String?> onCategoryChanged;

  const LocalizedChoiceChips({
    super.key,
    this.selectedCategory,
    required this.onCategoryChanged,
  });

  @override
  State<LocalizedChoiceChips> createState() => _LocalizedChoiceChipsState();
}

class _LocalizedChoiceChipsState extends State<LocalizedChoiceChips> {
  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    final categories = [
      ('all', l10n.categoryAll),
      ('electronics', l10n.categoryElectronics),
      ('clothing', l10n.categoryClothing),
      ('books', l10n.categoryBooks),
      ('food', l10n.categoryFood),
      ('sports', l10n.categorySports),
    ];

    return SingleChildScrollView(
      scrollDirection: Axis.horizontal,
      child: Row(
        children: categories.map((category) {
          final (id, label) = category;
          final isSelected = widget.selectedCategory == id;

          return Padding(
            padding: const EdgeInsets.only(right: 8),
            child: ChoiceChip(
              label: Text(label),
              selected: isSelected,
              onSelected: (selected) {
                widget.onCategoryChanged(selected ? id : null);
              },
            ),
          );
        }).toList(),
      ),
    );
  }
}

InputChip for Complex Information

class LocalizedInputChips extends StatelessWidget {
  final List<UserContact> contacts;
  final ValueChanged<UserContact> onContactRemoved;

  const LocalizedInputChips({
    super.key,
    required this.contacts,
    required this.onContactRemoved,
  });

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

    return Wrap(
      spacing: 8,
      runSpacing: 8,
      children: contacts.map((contact) {
        return InputChip(
          avatar: contact.avatarUrl != null
              ? CircleAvatar(
                  backgroundImage: NetworkImage(contact.avatarUrl!),
                )
              : CircleAvatar(
                  child: Text(contact.name[0].toUpperCase()),
                ),
          label: Text(contact.name),
          deleteIcon: const Icon(Icons.close, size: 18),
          deleteButtonTooltipMessage: l10n.removeContact(contact.name),
          onDeleted: () => onContactRemoved(contact),
          onPressed: () {
            // Show contact details
          },
        );
      }).toList(),
    );
  }
}

class UserContact {
  final String id;
  final String name;
  final String? avatarUrl;

  UserContact({required this.id, required this.name, this.avatarUrl});
}

ActionChip for Triggering Actions

class LocalizedActionChips extends StatelessWidget {
  final VoidCallback onShare;
  final VoidCallback onSave;
  final VoidCallback onReport;

  const LocalizedActionChips({
    super.key,
    required this.onShare,
    required this.onSave,
    required this.onReport,
  });

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

    return Wrap(
      spacing: 8,
      runSpacing: 8,
      children: [
        ActionChip(
          avatar: const Icon(Icons.share, size: 18),
          label: Text(l10n.actionShare),
          tooltip: l10n.shareTooltip,
          onPressed: onShare,
        ),
        ActionChip(
          avatar: const Icon(Icons.bookmark_add, size: 18),
          label: Text(l10n.actionSave),
          tooltip: l10n.saveTooltip,
          onPressed: onSave,
        ),
        ActionChip(
          avatar: const Icon(Icons.flag, size: 18),
          label: Text(l10n.actionReport),
          tooltip: l10n.reportTooltip,
          onPressed: onReport,
        ),
      ],
    );
  }
}

Size Selection Chips

class LocalizedSizeSelector extends StatelessWidget {
  final String? selectedSize;
  final List<String> availableSizes;
  final ValueChanged<String?> onSizeChanged;

  const LocalizedSizeSelector({
    super.key,
    this.selectedSize,
    required this.availableSizes,
    required this.onSizeChanged,
  });

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

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          l10n.selectSize,
          style: Theme.of(context).textTheme.titleSmall,
        ),
        const SizedBox(height: 8),
        Wrap(
          spacing: 8,
          runSpacing: 8,
          children: availableSizes.map((size) {
            final label = _getLocalizedSize(size, l10n);
            final isSelected = selectedSize == size;

            return ChoiceChip(
              label: Text(label),
              selected: isSelected,
              onSelected: (selected) {
                onSizeChanged(selected ? size : null);
              },
            );
          }).toList(),
        ),
      ],
    );
  }

  String _getLocalizedSize(String size, AppLocalizations l10n) {
    return switch (size) {
      'S' => l10n.sizeSmall,
      'M' => l10n.sizeMedium,
      'L' => l10n.sizeLarge,
      'XL' => l10n.sizeExtraLarge,
      _ => size,
    };
  }
}

Color Selection Chips

class LocalizedColorSelector extends StatelessWidget {
  final String? selectedColor;
  final List<ColorOption> availableColors;
  final ValueChanged<String?> onColorChanged;

  const LocalizedColorSelector({
    super.key,
    this.selectedColor,
    required this.availableColors,
    required this.onColorChanged,
  });

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

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          l10n.selectColor,
          style: Theme.of(context).textTheme.titleSmall,
        ),
        const SizedBox(height: 8),
        Wrap(
          spacing: 8,
          runSpacing: 8,
          children: availableColors.map((colorOption) {
            final label = _getLocalizedColor(colorOption.id, l10n);
            final isSelected = selectedColor == colorOption.id;

            return ChoiceChip(
              avatar: Container(
                width: 20,
                height: 20,
                decoration: BoxDecoration(
                  color: colorOption.color,
                  shape: BoxShape.circle,
                  border: Border.all(
                    color: Colors.grey.shade300,
                    width: 1,
                  ),
                ),
              ),
              label: Text(label),
              selected: isSelected,
              onSelected: (selected) {
                onColorChanged(selected ? colorOption.id : null);
              },
            );
          }).toList(),
        ),
      ],
    );
  }

  String _getLocalizedColor(String colorId, AppLocalizations l10n) {
    return switch (colorId) {
      'red' => l10n.colorRed,
      'blue' => l10n.colorBlue,
      'green' => l10n.colorGreen,
      'black' => l10n.colorBlack,
      'white' => l10n.colorWhite,
      _ => colorId,
    };
  }
}

class ColorOption {
  final String id;
  final Color color;

  ColorOption({required this.id, required this.color});
}

Rating Filter Chips

class LocalizedRatingFilter extends StatelessWidget {
  final int? minimumRating;
  final ValueChanged<int?> onRatingChanged;

  const LocalizedRatingFilter({
    super.key,
    this.minimumRating,
    required this.onRatingChanged,
  });

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

    return Wrap(
      spacing: 8,
      runSpacing: 8,
      children: [4, 3, 2, 1].map((rating) {
        final isSelected = minimumRating == rating;

        return ChoiceChip(
          avatar: Icon(
            Icons.star,
            size: 18,
            color: isSelected ? Colors.amber : Colors.grey,
          ),
          label: Text(l10n.ratingStars(rating)),
          selected: isSelected,
          onSelected: (selected) {
            onRatingChanged(selected ? rating : null);
          },
        );
      }).toList(),
    );
  }
}

Accessibility Considerations

class AccessibleChip extends StatelessWidget {
  final String label;
  final bool isSelected;
  final VoidCallback? onTap;
  final VoidCallback? onDelete;

  const AccessibleChip({
    super.key,
    required this.label,
    this.isSelected = false,
    this.onTap,
    this.onDelete,
  });

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

    return Semantics(
      button: true,
      selected: isSelected,
      label: isSelected
          ? l10n.chipSelectedLabel(label)
          : l10n.chipUnselectedLabel(label),
      child: InputChip(
        label: Text(label),
        selected: isSelected,
        onPressed: onTap,
        deleteIcon: onDelete != null ? const Icon(Icons.close, size: 18) : null,
        deleteButtonTooltipMessage: onDelete != null ? l10n.removeTag : null,
        onDeleted: onDelete,
      ),
    );
  }
}

Testing Chip Localization

void main() {
  group('Chip Localization Tests', () {
    testWidgets('displays localized filter chips', (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          locale: const Locale('es'),
          home: Scaffold(
            body: LocalizedFilterChips(
              onFiltersChanged: (_) {},
            ),
          ),
        ),
      );

      expect(find.text('En Stock'), findsOneWidget);
      expect(find.text('Envio Gratis'), findsOneWidget);
    });

    testWidgets('displays localized category chips', (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          locale: const Locale('ar'),
          home: Scaffold(
            body: LocalizedChoiceChips(
              onCategoryChanged: (_) {},
            ),
          ),
        ),
      );

      expect(find.text('الكل'), findsOneWidget);
      expect(find.text('إلكترونيات'), findsOneWidget);
    });

    testWidgets('shows correct filter count pluralization', (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          locale: const Locale('es'),
          home: Scaffold(
            body: Builder(
              builder: (context) {
                final l10n = AppLocalizations.of(context)!;
                return Chip(label: Text(l10n.filtersApplied(3)));
              },
            ),
          ),
        ),
      );

      expect(find.text('3 filtros'), findsOneWidget);
    });
  });
}

Best Practices Summary

  1. Use semantic labels: Provide clear accessibility labels for chip selection states
  2. Localize all text: Labels, tooltips, delete button messages
  3. Handle pluralization: Filter counts, rating labels
  4. Consider text length: Chips may need to wrap or scroll with longer translations
  5. Maintain icon clarity: Icons should remain meaningful across cultures
  6. Test RTL layouts: Ensure chips display correctly in RTL languages

Conclusion

Chips are versatile UI components that appear throughout Flutter applications. By properly localizing labels, tooltips, and interactive elements, you ensure these compact controls remain useful and accessible to users in any language.

Remember to test your chip implementations with various text lengths, as translations can significantly affect layout. Use horizontal scrolling or wrapping as needed to accommodate longer labels in certain languages.