← Back to Blog

Flutter FilterChip Localization: Multi-Select Filters for Multilingual Apps

flutterfilterchipchipfilterslocalizationrtl

Flutter FilterChip Localization: Multi-Select Filters for Multilingual Apps

FilterChip is a Flutter Material widget that represents a filter option in a group, allowing users to select multiple criteria simultaneously. In multilingual applications, FilterChip is essential for displaying translated filter labels that users can toggle on and off, 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 FilterChip in Localization Context

FilterChip renders a Material Design chip with a checkmark that appears when selected. For multilingual apps, this enables:

  • Translated filter labels in a compact, wrappable layout
  • Multi-select filtering with localized category names
  • RTL-aware chip flow using Wrap that reverses automatically
  • Active filter counts with parameterized translated labels

Why FilterChip Matters for Multilingual Apps

FilterChip provides:

  • Multi-select: Users can activate multiple translated filters simultaneously
  • Visual feedback: A checkmark appears on selected chips in all languages
  • Compact layout: Chips wrap to fit available width regardless of translation length
  • Accessibility: Selected state is announced in the active language by screen readers

Basic FilterChip Implementation

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

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

  @override
  State<LocalizedFilterChipExample> createState() =>
      _LocalizedFilterChipExampleState();
}

class _LocalizedFilterChipExampleState
    extends State<LocalizedFilterChipExample> {
  final Set<String> _selectedFilters = {};

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

    final filters = {
      'electronics': l10n.electronicsCategory,
      'clothing': l10n.clothingCategory,
      'books': l10n.booksCategory,
      'sports': l10n.sportsCategory,
      'home': l10n.homeCategory,
      'toys': l10n.toysCategory,
    };

    return Scaffold(
      appBar: AppBar(title: Text(l10n.filterProductsTitle)),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              l10n.categoriesLabel,
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 8),
            Wrap(
              spacing: 8,
              runSpacing: 4,
              children: filters.entries.map((entry) {
                final isSelected = _selectedFilters.contains(entry.key);
                return FilterChip(
                  label: Text(entry.value),
                  selected: isSelected,
                  onSelected: (selected) {
                    setState(() {
                      if (selected) {
                        _selectedFilters.add(entry.key);
                      } else {
                        _selectedFilters.remove(entry.key);
                      }
                    });
                  },
                );
              }).toList(),
            ),
            const SizedBox(height: 16),
            Text(
              _selectedFilters.isEmpty
                  ? l10n.noFiltersApplied
                  : l10n.activeFiltersCount(_selectedFilters.length),
              style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                    color: Theme.of(context).colorScheme.onSurfaceVariant,
                  ),
            ),
          ],
        ),
      ),
    );
  }
}

Advanced FilterChip Patterns for Localization

Grouped Filters with Section Headers

FilterChips organized into localized groups with translated section headers.

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

  @override
  State<GroupedFilterChips> createState() => _GroupedFilterChipsState();
}

class _GroupedFilterChipsState extends State<GroupedFilterChips> {
  final Set<String> _selectedSizes = {};
  final Set<String> _selectedColors = {};
  final Set<String> _selectedBrands = {};

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

    return SingleChildScrollView(
      padding: const EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          _FilterSection(
            title: l10n.sizeLabel,
            filters: {
              'xs': l10n.extraSmallLabel,
              's': l10n.smallLabel,
              'm': l10n.mediumLabel,
              'l': l10n.largeLabel,
              'xl': l10n.extraLargeLabel,
            },
            selected: _selectedSizes,
            onChanged: (key, selected) {
              setState(() {
                selected
                    ? _selectedSizes.add(key)
                    : _selectedSizes.remove(key);
              });
            },
          ),
          const SizedBox(height: 16),
          _FilterSection(
            title: l10n.colorLabel,
            filters: {
              'red': l10n.redColor,
              'blue': l10n.blueColor,
              'green': l10n.greenColor,
              'black': l10n.blackColor,
              'white': l10n.whiteColor,
            },
            selected: _selectedColors,
            onChanged: (key, selected) {
              setState(() {
                selected
                    ? _selectedColors.add(key)
                    : _selectedColors.remove(key);
              });
            },
          ),
          const SizedBox(height: 16),
          _FilterSection(
            title: l10n.brandLabel,
            filters: {
              'brand_a': l10n.brandALabel,
              'brand_b': l10n.brandBLabel,
              'brand_c': l10n.brandCLabel,
            },
            selected: _selectedBrands,
            onChanged: (key, selected) {
              setState(() {
                selected
                    ? _selectedBrands.add(key)
                    : _selectedBrands.remove(key);
              });
            },
          ),
          const SizedBox(height: 24),
          FilledButton.icon(
            onPressed: () {
              setState(() {
                _selectedSizes.clear();
                _selectedColors.clear();
                _selectedBrands.clear();
              });
            },
            icon: const Icon(Icons.clear_all),
            label: Text(l10n.clearAllFiltersLabel),
          ),
        ],
      ),
    );
  }
}

class _FilterSection extends StatelessWidget {
  final String title;
  final Map<String, String> filters;
  final Set<String> selected;
  final void Function(String key, bool selected) onChanged;

  const _FilterSection({
    required this.title,
    required this.filters,
    required this.selected,
    required this.onChanged,
  });

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          title,
          style: Theme.of(context).textTheme.titleSmall,
        ),
        const SizedBox(height: 8),
        Wrap(
          spacing: 8,
          runSpacing: 4,
          children: filters.entries.map((entry) {
            return FilterChip(
              label: Text(entry.value),
              selected: selected.contains(entry.key),
              onSelected: (value) => onChanged(entry.key, value),
            );
          }).toList(),
        ),
      ],
    );
  }
}

Filter Chips with Icons and Counts

FilterChips that show translated labels with leading icons and result counts.

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

  @override
  State<FilterChipsWithCounts> createState() => _FilterChipsWithCountsState();
}

class _FilterChipsWithCountsState extends State<FilterChipsWithCounts> {
  final Set<String> _selectedStatuses = {};

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

    final statusFilters = [
      _FilterItem(
        key: 'active',
        label: l10n.activeStatus,
        icon: Icons.check_circle_outline,
        count: 24,
      ),
      _FilterItem(
        key: 'pending',
        label: l10n.pendingStatus,
        icon: Icons.schedule,
        count: 8,
      ),
      _FilterItem(
        key: 'completed',
        label: l10n.completedStatus,
        icon: Icons.done_all,
        count: 156,
      ),
      _FilterItem(
        key: 'cancelled',
        label: l10n.cancelledStatus,
        icon: Icons.cancel_outlined,
        count: 3,
      ),
    ];

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Padding(
          padding: const EdgeInsetsDirectional.fromSTEB(16, 16, 16, 8),
          child: Text(
            l10n.filterByStatusLabel,
            style: Theme.of(context).textTheme.titleSmall,
          ),
        ),
        SingleChildScrollView(
          scrollDirection: Axis.horizontal,
          padding: const EdgeInsetsDirectional.only(start: 16),
          child: Row(
            children: statusFilters.map((filter) {
              final isSelected = _selectedStatuses.contains(filter.key);
              return Padding(
                padding: const EdgeInsetsDirectional.only(end: 8),
                child: FilterChip(
                  avatar: Icon(filter.icon, size: 18),
                  label: Text('${filter.label} (${filter.count})'),
                  selected: isSelected,
                  onSelected: (selected) {
                    setState(() {
                      selected
                          ? _selectedStatuses.add(filter.key)
                          : _selectedStatuses.remove(filter.key);
                    });
                  },
                ),
              );
            }).toList(),
          ),
        ),
      ],
    );
  }
}

class _FilterItem {
  final String key;
  final String label;
  final IconData icon;
  final int count;

  _FilterItem({
    required this.key,
    required this.label,
    required this.icon,
    required this.count,
  });
}

Bottom Sheet Filter Panel

A modal bottom sheet with organized FilterChips for a complete filter experience.

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

  @override
  State<FilterBottomSheet> createState() => _FilterBottomSheetState();
}

class _FilterBottomSheetState extends State<FilterBottomSheet> {
  final Set<String> _priceRanges = {};
  final Set<String> _ratings = {};

  void _showFilterSheet() {
    final l10n = AppLocalizations.of(context)!;

    showModalBottomSheet(
      context: context,
      builder: (context) {
        return StatefulBuilder(
          builder: (context, setSheetState) {
            return Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                mainAxisSize: MainAxisSize.min,
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    children: [
                      Text(
                        l10n.filtersTitle,
                        style: Theme.of(context).textTheme.titleLarge,
                      ),
                      TextButton(
                        onPressed: () {
                          setSheetState(() {
                            _priceRanges.clear();
                            _ratings.clear();
                          });
                          setState(() {});
                        },
                        child: Text(l10n.resetLabel),
                      ),
                    ],
                  ),
                  const SizedBox(height: 16),
                  Text(
                    l10n.priceRangeLabel,
                    style: Theme.of(context).textTheme.titleSmall,
                  ),
                  const SizedBox(height: 8),
                  Wrap(
                    spacing: 8,
                    runSpacing: 4,
                    children: [
                      _buildPriceChip(setSheetState, 'under25', l10n.under25Label),
                      _buildPriceChip(setSheetState, '25to50', l10n.range25to50Label),
                      _buildPriceChip(setSheetState, '50to100', l10n.range50to100Label),
                      _buildPriceChip(setSheetState, 'over100', l10n.over100Label),
                    ],
                  ),
                  const SizedBox(height: 16),
                  Text(
                    l10n.ratingLabel,
                    style: Theme.of(context).textTheme.titleSmall,
                  ),
                  const SizedBox(height: 8),
                  Wrap(
                    spacing: 8,
                    runSpacing: 4,
                    children: [
                      _buildRatingChip(setSheetState, '4plus', l10n.fourStarsAndUp),
                      _buildRatingChip(setSheetState, '3plus', l10n.threeStarsAndUp),
                      _buildRatingChip(setSheetState, '2plus', l10n.twoStarsAndUp),
                    ],
                  ),
                  const SizedBox(height: 24),
                  SizedBox(
                    width: double.infinity,
                    child: FilledButton(
                      onPressed: () => Navigator.pop(context),
                      child: Text(l10n.applyFiltersLabel),
                    ),
                  ),
                ],
              ),
            );
          },
        );
      },
    );
  }

  FilterChip _buildPriceChip(
      StateSetter setSheetState, String key, String label) {
    return FilterChip(
      label: Text(label),
      selected: _priceRanges.contains(key),
      onSelected: (selected) {
        setSheetState(() {
          selected ? _priceRanges.add(key) : _priceRanges.remove(key);
        });
        setState(() {});
      },
    );
  }

  FilterChip _buildRatingChip(
      StateSetter setSheetState, String key, String label) {
    return FilterChip(
      label: Text(label),
      selected: _ratings.contains(key),
      onSelected: (selected) {
        setSheetState(() {
          selected ? _ratings.add(key) : _ratings.remove(key);
        });
        setState(() {});
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final totalFilters = _priceRanges.length + _ratings.length;

    return Scaffold(
      appBar: AppBar(
        title: Text(l10n.productsTitle),
        actions: [
          Badge(
            isLabelVisible: totalFilters > 0,
            label: Text('$totalFilters'),
            child: IconButton(
              icon: const Icon(Icons.filter_list),
              tooltip: l10n.filtersTooltip,
              onPressed: _showFilterSheet,
            ),
          ),
        ],
      ),
      body: const Center(child: Placeholder()),
    );
  }
}

RTL Support and Bidirectional Layouts

FilterChip works correctly in RTL layouts. When placed inside a Wrap, chips flow from right to left automatically. The checkmark icon also positions correctly.

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

  @override
  State<BidirectionalFilterChips> createState() =>
      _BidirectionalFilterChipsState();
}

class _BidirectionalFilterChipsState extends State<BidirectionalFilterChips> {
  final Set<String> _selected = {};

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

    final options = {
      'new': l10n.newArrivalLabel,
      'sale': l10n.onSaleLabel,
      'popular': l10n.popularLabel,
      'free_shipping': l10n.freeShippingLabel,
    };

    return Padding(
      padding: const EdgeInsetsDirectional.all(16),
      child: Wrap(
        spacing: 8,
        runSpacing: 4,
        children: options.entries.map((entry) {
          return FilterChip(
            label: Text(entry.value),
            selected: _selected.contains(entry.key),
            onSelected: (selected) {
              setState(() {
                selected
                    ? _selected.add(entry.key)
                    : _selected.remove(entry.key);
              });
            },
          );
        }).toList(),
      ),
    );
  }
}

Testing FilterChip 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 LocalizedFilterChipExample(),
    );
  }

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

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

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

Best Practices

  1. Use Wrap to layout FilterChips so they flow to the next line when translated labels make them wider than the available space.

  2. Show active filter count with a parameterized translated label like activeFiltersCount(n) so users know how many filters are applied.

  3. Provide a "Clear all" action with a translated label to let users reset all filters at once.

  4. Group related filters under translated section headers to organize complex filter panels.

  5. Use SingleChildScrollView with horizontal scroll for single-row filter strips where vertical wrapping isn't appropriate.

  6. Test with verbose languages (German, Finnish) to verify chips wrap correctly and don't overflow their container.

Conclusion

FilterChip provides a multi-select filter widget for Flutter apps. For multilingual apps, it handles translated filter labels with checkmark selection, wraps correctly in RTL layouts, and combines naturally with Wrap for responsive chip flows. By combining FilterChip with grouped sections, icon-labeled filters, and bottom sheet panels, you can build filter experiences that communicate clearly in every supported language.

Further Reading