← Back to Blog

Building a Custom Language Picker Widget in Flutter: Complete Guide

flutterwidgetlanguage-pickeruilocalizationaccessibility

Building a Custom Language Picker Widget in Flutter: Complete Guide

Every multilingual Flutter app needs a way for users to switch languages. This guide shows you how to build beautiful, accessible language picker widgets that work seamlessly with Flutter's localization system.

What You'll Build

By the end of this guide, you'll have:

  • A dropdown language selector
  • A bottom sheet language picker with flags
  • A full-screen language selection page
  • A compact icon-only selector for app bars

Prerequisites

Make sure you have Flutter localization set up. If not, check our Flutter ARB guide first.

Basic Dropdown Language Picker

Start with a simple dropdown selector:

import 'package:flutter/material.dart';

class LanguageDropdown extends StatelessWidget {
  final Locale currentLocale;
  final List<Locale> supportedLocales;
  final ValueChanged<Locale> onLocaleChanged;

  const LanguageDropdown({
    super.key,
    required this.currentLocale,
    required this.supportedLocales,
    required this.onLocaleChanged,
  });

  @override
  Widget build(BuildContext context) {
    return DropdownButton<Locale>(
      value: currentLocale,
      onChanged: (locale) {
        if (locale != null) {
          onLocaleChanged(locale);
        }
      },
      items: supportedLocales.map((locale) {
        return DropdownMenuItem<Locale>(
          value: locale,
          child: Text(_getLanguageName(locale)),
        );
      }).toList(),
    );
  }

  String _getLanguageName(Locale locale) {
    switch (locale.languageCode) {
      case 'en':
        return 'English';
      case 'es':
        return 'Español';
      case 'fr':
        return 'Français';
      case 'de':
        return 'Deutsch';
      case 'ar':
        return 'العربية';
      case 'zh':
        return '中文';
      case 'ja':
        return '日本語';
      case 'ko':
        return '한국어';
      case 'pt':
        return 'Português';
      case 'ru':
        return 'Русский';
      default:
        return locale.languageCode;
    }
  }
}

Language Model with Flags

Create a model to store language info:

class LanguageOption {
  final Locale locale;
  final String name;
  final String nativeName;
  final String flag;

  const LanguageOption({
    required this.locale,
    required this.name,
    required this.nativeName,
    required this.flag,
  });

  static const List<LanguageOption> all = [
    LanguageOption(
      locale: Locale('en'),
      name: 'English',
      nativeName: 'English',
      flag: '🇺🇸',
    ),
    LanguageOption(
      locale: Locale('es'),
      name: 'Spanish',
      nativeName: 'Español',
      flag: '🇪🇸',
    ),
    LanguageOption(
      locale: Locale('fr'),
      name: 'French',
      nativeName: 'Français',
      flag: '🇫🇷',
    ),
    LanguageOption(
      locale: Locale('de'),
      name: 'German',
      nativeName: 'Deutsch',
      flag: '🇩🇪',
    ),
    LanguageOption(
      locale: Locale('ar'),
      name: 'Arabic',
      nativeName: 'العربية',
      flag: '🇸🇦',
    ),
    LanguageOption(
      locale: Locale('zh'),
      name: 'Chinese',
      nativeName: '中文',
      flag: '🇨🇳',
    ),
    LanguageOption(
      locale: Locale('ja'),
      name: 'Japanese',
      nativeName: '日本語',
      flag: '🇯🇵',
    ),
    LanguageOption(
      locale: Locale('ko'),
      name: 'Korean',
      nativeName: '한국어',
      flag: '🇰🇷',
    ),
    LanguageOption(
      locale: Locale('pt'),
      name: 'Portuguese',
      nativeName: 'Português',
      flag: '🇧🇷',
    ),
    LanguageOption(
      locale: Locale('ru'),
      name: 'Russian',
      nativeName: 'Русский',
      flag: '🇷🇺',
    ),
    LanguageOption(
      locale: Locale('it'),
      name: 'Italian',
      nativeName: 'Italiano',
      flag: '🇮🇹',
    ),
    LanguageOption(
      locale: Locale('nl'),
      name: 'Dutch',
      nativeName: 'Nederlands',
      flag: '🇳🇱',
    ),
    LanguageOption(
      locale: Locale('hi'),
      name: 'Hindi',
      nativeName: 'हिन्दी',
      flag: '🇮🇳',
    ),
    LanguageOption(
      locale: Locale('tr'),
      name: 'Turkish',
      nativeName: 'Türkçe',
      flag: '🇹🇷',
    ),
  ];

  static LanguageOption? fromLocale(Locale locale) {
    try {
      return all.firstWhere(
        (lang) => lang.locale.languageCode == locale.languageCode,
      );
    } catch (_) {
      return null;
    }
  }
}

Bottom Sheet Language Picker

A beautiful bottom sheet picker with flags:

class LanguageBottomSheet extends StatelessWidget {
  final Locale currentLocale;
  final ValueChanged<Locale> onLocaleChanged;

  const LanguageBottomSheet({
    super.key,
    required this.currentLocale,
    required this.onLocaleChanged,
  });

  static Future<void> show(
    BuildContext context, {
    required Locale currentLocale,
    required ValueChanged<Locale> onLocaleChanged,
  }) {
    return showModalBottomSheet(
      context: context,
      isScrollControlled: true,
      shape: const RoundedRectangleBorder(
        borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
      ),
      builder: (context) => LanguageBottomSheet(
        currentLocale: currentLocale,
        onLocaleChanged: onLocaleChanged,
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);

    return DraggableScrollableSheet(
      initialChildSize: 0.6,
      minChildSize: 0.4,
      maxChildSize: 0.9,
      expand: false,
      builder: (context, scrollController) {
        return Column(
          children: [
            // Handle bar
            Container(
              margin: const EdgeInsets.only(top: 12, bottom: 8),
              width: 40,
              height: 4,
              decoration: BoxDecoration(
                color: theme.colorScheme.onSurface.withOpacity(0.3),
                borderRadius: BorderRadius.circular(2),
              ),
            ),

            // Title
            Padding(
              padding: const EdgeInsets.all(16),
              child: Text(
                'Select Language',
                style: theme.textTheme.titleLarge?.copyWith(
                  fontWeight: FontWeight.bold,
                ),
              ),
            ),

            const Divider(height: 1),

            // Language list
            Expanded(
              child: ListView.builder(
                controller: scrollController,
                itemCount: LanguageOption.all.length,
                itemBuilder: (context, index) {
                  final language = LanguageOption.all[index];
                  final isSelected =
                      language.locale.languageCode == currentLocale.languageCode;

                  return ListTile(
                    leading: Text(
                      language.flag,
                      style: const TextStyle(fontSize: 28),
                    ),
                    title: Text(language.nativeName),
                    subtitle: Text(language.name),
                    trailing: isSelected
                        ? Icon(
                            Icons.check_circle,
                            color: theme.colorScheme.primary,
                          )
                        : null,
                    selected: isSelected,
                    onTap: () {
                      onLocaleChanged(language.locale);
                      Navigator.pop(context);
                    },
                  );
                },
              ),
            ),
          ],
        );
      },
    );
  }
}

// Usage:
IconButton(
  icon: const Icon(Icons.language),
  onPressed: () => LanguageBottomSheet.show(
    context,
    currentLocale: currentLocale,
    onLocaleChanged: (locale) {
      // Update app locale
    },
  ),
)

Full-Screen Language Selection Page

For onboarding or settings:

class LanguageSelectionPage extends StatefulWidget {
  final Locale initialLocale;

  const LanguageSelectionPage({
    super.key,
    required this.initialLocale,
  });

  @override
  State<LanguageSelectionPage> createState() => _LanguageSelectionPageState();
}

class _LanguageSelectionPageState extends State<LanguageSelectionPage> {
  late Locale _selectedLocale;
  String _searchQuery = '';

  @override
  void initState() {
    super.initState();
    _selectedLocale = widget.initialLocale;
  }

  List<LanguageOption> get _filteredLanguages {
    if (_searchQuery.isEmpty) {
      return LanguageOption.all;
    }

    final query = _searchQuery.toLowerCase();
    return LanguageOption.all.where((lang) {
      return lang.name.toLowerCase().contains(query) ||
          lang.nativeName.toLowerCase().contains(query) ||
          lang.locale.languageCode.contains(query);
    }).toList();
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Language'),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context, _selectedLocale),
            child: const Text('Done'),
          ),
        ],
      ),
      body: Column(
        children: [
          // Search bar
          Padding(
            padding: const EdgeInsets.all(16),
            child: TextField(
              decoration: InputDecoration(
                hintText: 'Search languages...',
                prefixIcon: const Icon(Icons.search),
                border: OutlineInputBorder(
                  borderRadius: BorderRadius.circular(12),
                ),
                filled: true,
              ),
              onChanged: (value) {
                setState(() => _searchQuery = value);
              },
            ),
          ),

          // Language grid
          Expanded(
            child: GridView.builder(
              padding: const EdgeInsets.all(16),
              gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
                crossAxisCount: 2,
                childAspectRatio: 2.5,
                crossAxisSpacing: 12,
                mainAxisSpacing: 12,
              ),
              itemCount: _filteredLanguages.length,
              itemBuilder: (context, index) {
                final language = _filteredLanguages[index];
                final isSelected = language.locale.languageCode ==
                    _selectedLocale.languageCode;

                return Material(
                  color: isSelected
                      ? theme.colorScheme.primaryContainer
                      : theme.colorScheme.surface,
                  borderRadius: BorderRadius.circular(12),
                  child: InkWell(
                    borderRadius: BorderRadius.circular(12),
                    onTap: () {
                      setState(() => _selectedLocale = language.locale);
                    },
                    child: Container(
                      padding: const EdgeInsets.symmetric(horizontal: 12),
                      decoration: BoxDecoration(
                        borderRadius: BorderRadius.circular(12),
                        border: Border.all(
                          color: isSelected
                              ? theme.colorScheme.primary
                              : theme.colorScheme.outline.withOpacity(0.3),
                          width: isSelected ? 2 : 1,
                        ),
                      ),
                      child: Row(
                        children: [
                          Text(
                            language.flag,
                            style: const TextStyle(fontSize: 24),
                          ),
                          const SizedBox(width: 8),
                          Expanded(
                            child: Column(
                              mainAxisAlignment: MainAxisAlignment.center,
                              crossAxisAlignment: CrossAxisAlignment.start,
                              children: [
                                Text(
                                  language.nativeName,
                                  style: theme.textTheme.bodyMedium?.copyWith(
                                    fontWeight: FontWeight.w600,
                                  ),
                                  overflow: TextOverflow.ellipsis,
                                ),
                                Text(
                                  language.name,
                                  style: theme.textTheme.bodySmall?.copyWith(
                                    color: theme.colorScheme.onSurface
                                        .withOpacity(0.6),
                                  ),
                                  overflow: TextOverflow.ellipsis,
                                ),
                              ],
                            ),
                          ),
                          if (isSelected)
                            Icon(
                              Icons.check,
                              color: theme.colorScheme.primary,
                              size: 20,
                            ),
                        ],
                      ),
                    ),
                  ),
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

// Usage:
final selectedLocale = await Navigator.push<Locale>(
  context,
  MaterialPageRoute(
    builder: (context) => LanguageSelectionPage(
      initialLocale: currentLocale,
    ),
  ),
);

if (selectedLocale != null) {
  // Update app locale
}

Compact App Bar Language Selector

A minimal flag-only button for app bars:

class CompactLanguageSelector extends StatelessWidget {
  final Locale currentLocale;
  final VoidCallback onTap;

  const CompactLanguageSelector({
    super.key,
    required this.currentLocale,
    required this.onTap,
  });

  @override
  Widget build(BuildContext context) {
    final language = LanguageOption.fromLocale(currentLocale);

    return Tooltip(
      message: language?.name ?? 'Change language',
      child: InkWell(
        borderRadius: BorderRadius.circular(8),
        onTap: onTap,
        child: Container(
          padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(8),
            border: Border.all(
              color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
            ),
          ),
          child: Row(
            mainAxisSize: MainAxisSize.min,
            children: [
              Text(
                language?.flag ?? '🌐',
                style: const TextStyle(fontSize: 20),
              ),
              const SizedBox(width: 4),
              Icon(
                Icons.arrow_drop_down,
                size: 20,
                color: Theme.of(context).colorScheme.onSurface,
              ),
            ],
          ),
        ),
      ),
    );
  }
}

// Usage in AppBar:
AppBar(
  title: const Text('My App'),
  actions: [
    CompactLanguageSelector(
      currentLocale: currentLocale,
      onTap: () => LanguageBottomSheet.show(
        context,
        currentLocale: currentLocale,
        onLocaleChanged: onLocaleChanged,
      ),
    ),
    const SizedBox(width: 8),
  ],
)

Popup Menu Language Selector

A dropdown menu that appears from any widget:

class LanguagePopupMenu extends StatelessWidget {
  final Locale currentLocale;
  final ValueChanged<Locale> onLocaleChanged;
  final Widget? child;

  const LanguagePopupMenu({
    super.key,
    required this.currentLocale,
    required this.onLocaleChanged,
    this.child,
  });

  @override
  Widget build(BuildContext context) {
    final currentLanguage = LanguageOption.fromLocale(currentLocale);

    return PopupMenuButton<Locale>(
      initialValue: currentLocale,
      onSelected: onLocaleChanged,
      offset: const Offset(0, 40),
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(12),
      ),
      itemBuilder: (context) {
        return LanguageOption.all.map((language) {
          final isSelected =
              language.locale.languageCode == currentLocale.languageCode;

          return PopupMenuItem<Locale>(
            value: language.locale,
            child: Row(
              children: [
                Text(language.flag, style: const TextStyle(fontSize: 20)),
                const SizedBox(width: 12),
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    mainAxisSize: MainAxisSize.min,
                    children: [
                      Text(
                        language.nativeName,
                        style: const TextStyle(fontWeight: FontWeight.w500),
                      ),
                      Text(
                        language.name,
                        style: TextStyle(
                          fontSize: 12,
                          color: Theme.of(context)
                              .colorScheme
                              .onSurface
                              .withOpacity(0.6),
                        ),
                      ),
                    ],
                  ),
                ),
                if (isSelected)
                  Icon(
                    Icons.check,
                    color: Theme.of(context).colorScheme.primary,
                    size: 18,
                  ),
              ],
            ),
          );
        }).toList();
      },
      child: child ??
          Chip(
            avatar: Text(currentLanguage?.flag ?? '🌐'),
            label: Text(currentLanguage?.nativeName ?? 'Language'),
          ),
    );
  }
}

// Usage:
LanguagePopupMenu(
  currentLocale: currentLocale,
  onLocaleChanged: (locale) {
    // Update app locale
  },
)

Integrating with State Management

With Provider

class LocaleProvider extends ChangeNotifier {
  Locale _locale = const Locale('en');

  Locale get locale => _locale;

  void setLocale(Locale locale) {
    _locale = locale;
    notifyListeners();
    _saveLocale(locale);
  }

  Future<void> loadSavedLocale() async {
    final prefs = await SharedPreferences.getInstance();
    final languageCode = prefs.getString('language_code');
    if (languageCode != null) {
      _locale = Locale(languageCode);
      notifyListeners();
    }
  }

  Future<void> _saveLocale(Locale locale) async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString('language_code', locale.languageCode);
  }
}

// In your widget:
Consumer<LocaleProvider>(
  builder: (context, provider, _) {
    return LanguagePopupMenu(
      currentLocale: provider.locale,
      onLocaleChanged: provider.setLocale,
    );
  },
)

With Riverpod

final localeProvider = StateNotifierProvider<LocaleNotifier, Locale>((ref) {
  return LocaleNotifier();
});

class LocaleNotifier extends StateNotifier<Locale> {
  LocaleNotifier() : super(const Locale('en')) {
    _loadSavedLocale();
  }

  Future<void> _loadSavedLocale() async {
    final prefs = await SharedPreferences.getInstance();
    final languageCode = prefs.getString('language_code');
    if (languageCode != null) {
      state = Locale(languageCode);
    }
  }

  Future<void> setLocale(Locale locale) async {
    state = locale;
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString('language_code', locale.languageCode);
  }
}

// In your widget:
Consumer(
  builder: (context, ref, _) {
    final locale = ref.watch(localeProvider);

    return LanguagePopupMenu(
      currentLocale: locale,
      onLocaleChanged: (newLocale) {
        ref.read(localeProvider.notifier).setLocale(newLocale);
      },
    );
  },
)

Accessibility Considerations

Make your language picker accessible:

class AccessibleLanguageSelector extends StatelessWidget {
  final Locale currentLocale;
  final ValueChanged<Locale> onLocaleChanged;

  const AccessibleLanguageSelector({
    super.key,
    required this.currentLocale,
    required this.onLocaleChanged,
  });

  @override
  Widget build(BuildContext context) {
    final language = LanguageOption.fromLocale(currentLocale);

    return Semantics(
      label: 'Current language: ${language?.name ?? "Unknown"}. '
          'Double tap to change language.',
      button: true,
      child: InkWell(
        onTap: () => _showLanguageDialog(context),
        child: Padding(
          padding: const EdgeInsets.all(8),
          child: Row(
            mainAxisSize: MainAxisSize.min,
            children: [
              ExcludeSemantics(
                child: Text(
                  language?.flag ?? '🌐',
                  style: const TextStyle(fontSize: 24),
                ),
              ),
              const SizedBox(width: 8),
              Text(language?.nativeName ?? 'Language'),
              const Icon(Icons.arrow_drop_down),
            ],
          ),
        ),
      ),
    );
  }

  void _showLanguageDialog(BuildContext context) {
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('Select Language'),
        content: SizedBox(
          width: double.maxFinite,
          child: ListView.builder(
            shrinkWrap: true,
            itemCount: LanguageOption.all.length,
            itemBuilder: (context, index) {
              final lang = LanguageOption.all[index];
              final isSelected =
                  lang.locale.languageCode == currentLocale.languageCode;

              return Semantics(
                selected: isSelected,
                child: ListTile(
                  leading: ExcludeSemantics(
                    child: Text(lang.flag, style: const TextStyle(fontSize: 24)),
                  ),
                  title: Text(lang.nativeName),
                  subtitle: Text(lang.name),
                  trailing: isSelected ? const Icon(Icons.check) : null,
                  onTap: () {
                    onLocaleChanged(lang.locale);
                    Navigator.pop(context);
                  },
                ),
              );
            },
          ),
        ),
      ),
    );
  }
}

Testing Your Language Picker

import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('shows current language', (tester) async {
    await tester.pumpWidget(
      MaterialApp(
        home: Scaffold(
          body: LanguagePopupMenu(
            currentLocale: const Locale('es'),
            onLocaleChanged: (_) {},
          ),
        ),
      ),
    );

    expect(find.text('Español'), findsOneWidget);
  });

  testWidgets('calls onLocaleChanged when language selected', (tester) async {
    Locale? selectedLocale;

    await tester.pumpWidget(
      MaterialApp(
        home: Scaffold(
          body: LanguagePopupMenu(
            currentLocale: const Locale('en'),
            onLocaleChanged: (locale) => selectedLocale = locale,
          ),
        ),
      ),
    );

    // Open popup
    await tester.tap(find.byType(LanguagePopupMenu));
    await tester.pumpAndSettle();

    // Select Spanish
    await tester.tap(find.text('Español'));
    await tester.pumpAndSettle();

    expect(selectedLocale, const Locale('es'));
  });
}

Conclusion

A well-designed language picker improves user experience and accessibility. Choose the right pattern for your app:

  • Dropdown: Simple apps, limited space
  • Bottom Sheet: Mobile-first apps
  • Full Page: Onboarding, many languages
  • Popup Menu: Settings screens
  • Compact Selector: App bars, toolbars

Remember to persist the user's choice and make your picker accessible to all users.

Need to manage translations for all those languages? FlutterLocalisation makes it easy to handle ARB files for any number of languages with team collaboration and AI-powered translations.