Flutter Checkbox Localization: Labels, States, and Accessibility
Checkboxes are essential form elements in Flutter apps. From terms acceptance to feature toggles, from multi-select lists to survey forms, checkboxes appear throughout mobile applications. Properly localizing checkbox components ensures users worldwide can interact with your app confidently.
Why Checkbox Localization Matters
Checkboxes often represent important decisions like accepting terms of service, opting into notifications, or selecting preferences. Poorly localized checkbox labels can lead to user confusion, legal issues, or incorrect data collection. A well-localized checkbox adapts text, accessibility labels, and layout direction.
Basic Checkbox Localization
Let's start with the fundamentals of localizing a Checkbox with its label:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedCheckbox extends StatefulWidget {
const LocalizedCheckbox({super.key});
@override
State<LocalizedCheckbox> createState() => _LocalizedCheckboxState();
}
class _LocalizedCheckboxState extends State<LocalizedCheckbox> {
bool _isChecked = false;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return CheckboxListTile(
title: Text(l10n.acceptTermsLabel),
subtitle: Text(l10n.acceptTermsDescription),
value: _isChecked,
onChanged: (value) {
setState(() {
_isChecked = value ?? false;
});
},
);
}
}
Your ARB file would include:
{
"acceptTermsLabel": "I accept the Terms of Service",
"@acceptTermsLabel": {
"description": "Label for terms acceptance checkbox"
},
"acceptTermsDescription": "You must accept to continue",
"@acceptTermsDescription": {
"description": "Helper text for terms checkbox"
}
}
Terms and Conditions Checkbox
Legal checkboxes require special attention:
class TermsCheckbox extends StatefulWidget {
final ValueChanged<bool> onChanged;
const TermsCheckbox({
super.key,
required this.onChanged,
});
@override
State<TermsCheckbox> createState() => _TermsCheckboxState();
}
class _TermsCheckboxState extends State<TermsCheckbox> {
bool _accepted = false;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return CheckboxListTile(
value: _accepted,
onChanged: (value) {
setState(() {
_accepted = value ?? false;
});
widget.onChanged(_accepted);
},
title: RichText(
text: TextSpan(
style: Theme.of(context).textTheme.bodyMedium,
children: [
TextSpan(text: l10n.termsPrefix),
TextSpan(
text: l10n.termsOfService,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
decoration: TextDecoration.underline,
),
),
TextSpan(text: l10n.termsMiddle),
TextSpan(
text: l10n.privacyPolicy,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
decoration: TextDecoration.underline,
),
),
],
),
),
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
);
}
}
With corresponding ARB entries:
{
"termsPrefix": "I agree to the ",
"@termsPrefix": {
"description": "Text before Terms of Service link"
},
"termsOfService": "Terms of Service",
"@termsOfService": {
"description": "Terms of Service link text"
},
"termsMiddle": " and ",
"@termsMiddle": {
"description": "Text between Terms and Privacy links"
},
"privacyPolicy": "Privacy Policy",
"@privacyPolicy": {
"description": "Privacy Policy link text"
}
}
Multi-Select Checkbox List
For selecting multiple items from a list:
class MultiSelectCheckboxList extends StatefulWidget {
final List<String> items;
final ValueChanged<List<String>> onSelectionChanged;
const MultiSelectCheckboxList({
super.key,
required this.items,
required this.onSelectionChanged,
});
@override
State<MultiSelectCheckboxList> createState() => _MultiSelectCheckboxListState();
}
class _MultiSelectCheckboxListState extends State<MultiSelectCheckboxList> {
final Set<String> _selectedItems = {};
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
l10n.selectInterests,
style: Theme.of(context).textTheme.titleMedium,
),
),
Text(
l10n.selectedCount(_selectedItems.length),
style: Theme.of(context).textTheme.bodySmall,
),
const Divider(),
...widget.items.map((item) => CheckboxListTile(
title: Text(_getLocalizedItemName(context, item)),
value: _selectedItems.contains(item),
onChanged: (selected) {
setState(() {
if (selected == true) {
_selectedItems.add(item);
} else {
_selectedItems.remove(item);
}
});
widget.onSelectionChanged(_selectedItems.toList());
},
)),
],
);
}
String _getLocalizedItemName(BuildContext context, String item) {
final l10n = AppLocalizations.of(context)!;
switch (item) {
case 'sports':
return l10n.interestSports;
case 'music':
return l10n.interestMusic;
case 'technology':
return l10n.interestTechnology;
case 'travel':
return l10n.interestTravel;
default:
return item;
}
}
}
ARB file with pluralization:
{
"selectInterests": "Select your interests",
"@selectInterests": {
"description": "Header for interest selection"
},
"selectedCount": "{count, plural, =0{No items selected} =1{1 item selected} other{{count} items selected}}",
"@selectedCount": {
"description": "Shows how many items are selected",
"placeholders": {
"count": {
"type": "int"
}
}
},
"interestSports": "Sports & Fitness",
"@interestSports": {
"description": "Sports interest category"
},
"interestMusic": "Music & Entertainment",
"@interestMusic": {
"description": "Music interest category"
},
"interestTechnology": "Technology & Gadgets",
"@interestTechnology": {
"description": "Technology interest category"
},
"interestTravel": "Travel & Adventure",
"@interestTravel": {
"description": "Travel interest category"
}
}
Checkbox with Validation
Checkboxes that require validation need localized error messages:
class ValidatedCheckboxForm extends StatefulWidget {
const ValidatedCheckboxForm({super.key});
@override
State<ValidatedCheckboxForm> createState() => _ValidatedCheckboxFormState();
}
class _ValidatedCheckboxFormState extends State<ValidatedCheckboxForm> {
final _formKey = GlobalKey<FormState>();
bool _termsAccepted = false;
bool _newsletterOptIn = false;
bool _ageConfirmed = false;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Form(
key: _formKey,
child: Column(
children: [
FormField<bool>(
initialValue: _termsAccepted,
validator: (value) {
if (value != true) {
return l10n.termsRequiredError;
}
return null;
},
builder: (state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CheckboxListTile(
title: Text(l10n.acceptTermsLabel),
value: _termsAccepted,
onChanged: (value) {
setState(() {
_termsAccepted = value ?? false;
});
state.didChange(value);
},
),
if (state.hasError)
Padding(
padding: const EdgeInsets.only(left: 16),
child: Text(
state.errorText!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
),
],
);
},
),
FormField<bool>(
initialValue: _ageConfirmed,
validator: (value) {
if (value != true) {
return l10n.ageConfirmationRequired;
}
return null;
},
builder: (state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CheckboxListTile(
title: Text(l10n.ageConfirmationLabel),
subtitle: Text(l10n.ageConfirmationHint),
value: _ageConfirmed,
onChanged: (value) {
setState(() {
_ageConfirmed = value ?? false;
});
state.didChange(value);
},
),
if (state.hasError)
Padding(
padding: const EdgeInsets.only(left: 16),
child: Text(
state.errorText!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
),
],
);
},
),
CheckboxListTile(
title: Text(l10n.newsletterOptInLabel),
subtitle: Text(l10n.newsletterOptInDescription),
value: _newsletterOptIn,
onChanged: (value) {
setState(() {
_newsletterOptIn = value ?? false;
});
},
),
ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
// Form is valid
}
},
child: Text(l10n.submitButton),
),
],
),
);
}
}
ARB validation messages:
{
"termsRequiredError": "You must accept the terms to continue",
"@termsRequiredError": {
"description": "Error when terms checkbox is not checked"
},
"ageConfirmationLabel": "I confirm I am 18 years or older",
"@ageConfirmationLabel": {
"description": "Age confirmation checkbox label"
},
"ageConfirmationHint": "Required for account creation",
"@ageConfirmationHint": {
"description": "Hint text for age confirmation"
},
"ageConfirmationRequired": "Age confirmation is required",
"@ageConfirmationRequired": {
"description": "Error when age not confirmed"
},
"newsletterOptInLabel": "Subscribe to our newsletter",
"@newsletterOptInLabel": {
"description": "Newsletter subscription checkbox label"
},
"newsletterOptInDescription": "Receive updates and special offers",
"@newsletterOptInDescription": {
"description": "Newsletter subscription description"
},
"submitButton": "Submit",
"@submitButton": {
"description": "Submit button text"
}
}
Checkbox Accessibility
Proper accessibility is crucial for checkbox localization:
class AccessibleCheckbox extends StatefulWidget {
final String label;
final String? semanticLabel;
final bool value;
final ValueChanged<bool?> onChanged;
const AccessibleCheckbox({
super.key,
required this.label,
this.semanticLabel,
required this.value,
required this.onChanged,
});
@override
State<AccessibleCheckbox> createState() => _AccessibleCheckboxState();
}
class _AccessibleCheckboxState extends State<AccessibleCheckbox> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Semantics(
checked: widget.value,
label: widget.semanticLabel ?? widget.label,
hint: widget.value
? l10n.checkboxCheckedHint
: l10n.checkboxUncheckedHint,
child: CheckboxListTile(
title: Text(widget.label),
value: widget.value,
onChanged: widget.onChanged,
),
);
}
}
ARB accessibility entries:
{
"checkboxCheckedHint": "Double tap to uncheck",
"@checkboxCheckedHint": {
"description": "Accessibility hint for checked checkbox"
},
"checkboxUncheckedHint": "Double tap to check",
"@checkboxUncheckedHint": {
"description": "Accessibility hint for unchecked checkbox"
}
}
RTL Support for Checkboxes
Handle right-to-left languages properly:
class RTLAwareCheckbox extends StatelessWidget {
final bool value;
final String label;
final ValueChanged<bool?> onChanged;
const RTLAwareCheckbox({
super.key,
required this.value,
required this.label,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final isRTL = Directionality.of(context) == TextDirection.rtl;
return CheckboxListTile(
title: Text(label),
value: value,
onChanged: onChanged,
controlAffinity: isRTL
? ListTileControlAffinity.trailing
: ListTileControlAffinity.leading,
);
}
}
Checkbox Group with Select All
A common pattern with full localization:
class SelectAllCheckboxGroup extends StatefulWidget {
final List<CheckboxItem> items;
final ValueChanged<List<String>> onSelectionChanged;
const SelectAllCheckboxGroup({
super.key,
required this.items,
required this.onSelectionChanged,
});
@override
State<SelectAllCheckboxGroup> createState() => _SelectAllCheckboxGroupState();
}
class _SelectAllCheckboxGroupState extends State<SelectAllCheckboxGroup> {
late Set<String> _selectedIds;
@override
void initState() {
super.initState();
_selectedIds = {};
}
bool get _allSelected => _selectedIds.length == widget.items.length;
bool get _someSelected => _selectedIds.isNotEmpty && !_allSelected;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
children: [
CheckboxListTile(
title: Text(
_allSelected ? l10n.deselectAll : l10n.selectAll,
style: const TextStyle(fontWeight: FontWeight.bold),
),
value: _allSelected,
tristate: true,
onChanged: (value) {
setState(() {
if (_allSelected || _someSelected) {
_selectedIds.clear();
} else {
_selectedIds = widget.items.map((e) => e.id).toSet();
}
});
widget.onSelectionChanged(_selectedIds.toList());
},
),
const Divider(),
...widget.items.map((item) => CheckboxListTile(
title: Text(item.label),
subtitle: item.description != null ? Text(item.description!) : null,
value: _selectedIds.contains(item.id),
onChanged: (selected) {
setState(() {
if (selected == true) {
_selectedIds.add(item.id);
} else {
_selectedIds.remove(item.id);
}
});
widget.onSelectionChanged(_selectedIds.toList());
},
)),
],
);
}
}
class CheckboxItem {
final String id;
final String label;
final String? description;
const CheckboxItem({
required this.id,
required this.label,
this.description,
});
}
ARB entries:
{
"selectAll": "Select all",
"@selectAll": {
"description": "Select all checkbox label"
},
"deselectAll": "Deselect all",
"@deselectAll": {
"description": "Deselect all checkbox label"
}
}
Tristate Checkbox
For parent-child checkbox relationships:
class TristateCheckboxExample extends StatefulWidget {
const TristateCheckboxExample({super.key});
@override
State<TristateCheckboxExample> createState() => _TristateCheckboxExampleState();
}
class _TristateCheckboxExampleState extends State<TristateCheckboxExample> {
bool _emailNotifications = true;
bool _pushNotifications = false;
bool _smsNotifications = false;
bool? get _allNotifications {
if (_emailNotifications && _pushNotifications && _smsNotifications) {
return true;
}
if (!_emailNotifications && !_pushNotifications && !_smsNotifications) {
return false;
}
return null;
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
children: [
CheckboxListTile(
title: Text(l10n.allNotifications),
subtitle: Text(_getNotificationSummary(l10n)),
value: _allNotifications,
tristate: true,
onChanged: (value) {
setState(() {
final newValue = value ?? false;
_emailNotifications = newValue;
_pushNotifications = newValue;
_smsNotifications = newValue;
});
},
),
Padding(
padding: const EdgeInsets.only(left: 32),
child: Column(
children: [
CheckboxListTile(
title: Text(l10n.emailNotifications),
value: _emailNotifications,
onChanged: (value) {
setState(() {
_emailNotifications = value ?? false;
});
},
),
CheckboxListTile(
title: Text(l10n.pushNotifications),
value: _pushNotifications,
onChanged: (value) {
setState(() {
_pushNotifications = value ?? false;
});
},
),
CheckboxListTile(
title: Text(l10n.smsNotifications),
value: _smsNotifications,
onChanged: (value) {
setState(() {
_smsNotifications = value ?? false;
});
},
),
],
),
),
],
);
}
String _getNotificationSummary(AppLocalizations l10n) {
final count = [_emailNotifications, _pushNotifications, _smsNotifications]
.where((e) => e)
.length;
return l10n.notificationsSummary(count, 3);
}
}
ARB file:
{
"allNotifications": "All notifications",
"@allNotifications": {
"description": "Parent checkbox for all notification types"
},
"emailNotifications": "Email notifications",
"@emailNotifications": {
"description": "Email notification checkbox"
},
"pushNotifications": "Push notifications",
"@pushNotifications": {
"description": "Push notification checkbox"
},
"smsNotifications": "SMS notifications",
"@smsNotifications": {
"description": "SMS notification checkbox"
},
"notificationsSummary": "{enabled} of {total} enabled",
"@notificationsSummary": {
"description": "Summary of enabled notifications",
"placeholders": {
"enabled": {
"type": "int"
},
"total": {
"type": "int"
}
}
}
}
Custom Styled Checkbox
Sometimes you need custom checkbox styling with localization:
class CustomStyledCheckbox extends StatelessWidget {
final bool value;
final String label;
final String? errorText;
final ValueChanged<bool> onChanged;
const CustomStyledCheckbox({
super.key,
required this.value,
required this.label,
this.errorText,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final hasError = errorText != null;
return InkWell(
onTap: () => onChanged(!value),
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(
color: hasError
? theme.colorScheme.error
: theme.colorScheme.outline,
),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 24,
height: 24,
decoration: BoxDecoration(
color: value
? theme.colorScheme.primary
: Colors.transparent,
border: Border.all(
color: value
? theme.colorScheme.primary
: theme.colorScheme.outline,
width: 2,
),
borderRadius: BorderRadius.circular(4),
),
child: value
? Icon(
Icons.check,
size: 18,
color: theme.colorScheme.onPrimary,
)
: null,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.bodyLarge,
),
if (hasError)
Text(
errorText!,
style: TextStyle(
color: theme.colorScheme.error,
fontSize: 12,
),
),
],
),
),
],
),
),
);
}
}
Best Practices
- Always provide context: Include descriptions in your ARB files for translators
- Handle validation: Localize all error messages for required checkboxes
- Consider RTL: Test checkbox layouts in RTL languages like Arabic and Hebrew
- Use semantics: Provide accessibility labels and hints
- Group related checkboxes: Use clear section headers for checkbox groups
- Tristate for parent-child: Use tristate checkboxes for hierarchical selections
Testing Checkbox Localization
testWidgets('Checkbox shows localized label', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('es'),
home: const Scaffold(
body: LocalizedCheckbox(),
),
),
);
expect(find.text('Acepto los Términos de Servicio'), findsOneWidget);
});
testWidgets('Checkbox validation shows localized error', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('fr'),
home: const Scaffold(
body: ValidatedCheckboxForm(),
),
),
);
await tester.tap(find.text('Soumettre'));
await tester.pump();
expect(find.text('Vous devez accepter les conditions'), findsOneWidget);
});
Conclusion
Checkbox localization in Flutter requires attention to labels, validation messages, accessibility, and layout direction. By following these patterns, you ensure your app provides a native experience for users regardless of their language or locale. Remember to test thoroughly with different locales and always provide meaningful context for translators in your ARB files.