Flutter Localization and Accessibility: Building Inclusive Multilingual Apps
Localization and accessibility go hand in hand. A truly global app must work for users of all abilities, in all languages. This guide covers how to make your Flutter app both multilingual and accessible.
Why Accessibility Matters for Localized Apps
The numbers:
- 1 billion people worldwide have disabilities
- 15% of the global population experiences some form of disability
- Screen reader usage is growing 30% year-over-year
- Accessible apps have 20% higher user satisfaction
The business case:
- Legal requirements in many countries (ADA, WCAG, EN 301 549)
- Larger potential user base
- Better SEO (search engines reward accessibility)
- Improved UX for all users
Accessible Localization Basics
Semantic Labels with Translation
Every interactive element needs a semantic label:
// Bad - Hardcoded, no translation
IconButton(
icon: Icon(Icons.menu),
onPressed: () {},
)
// Good - Translated semantic label
IconButton(
icon: Icon(Icons.menu),
onPressed: () {},
tooltip: AppLocalizations.of(context)!.openMenu,
)
In your ARB file:
{
"openMenu": "Open menu",
"@openMenu": {
"description": "Accessibility label for the menu button"
}
}
Semantics Widget for Complex Elements
Semantics(
label: AppLocalizations.of(context)!.productCard(productName, price),
child: ProductCard(product: product),
)
ARB with placeholders:
{
"productCard": "{name}, priced at {price}",
"@productCard": {
"description": "Screen reader description for product card",
"placeholders": {
"name": {"type": "String"},
"price": {"type": "String"}
}
}
}
ExcludeSemantics for Decorative Elements
// Decorative image - don't announce
ExcludeSemantics(
child: Image.asset('assets/decorative_pattern.png'),
)
// Meaningful image - translate the description
Semantics(
label: AppLocalizations.of(context)!.companyLogo,
child: Image.asset('assets/logo.png'),
)
Screen Reader Considerations by Language
Text Direction Announcements
Screen readers need to know text direction:
Directionality(
textDirection: isRTL ? TextDirection.rtl : TextDirection.ltr,
child: Semantics(
textDirection: isRTL ? TextDirection.rtl : TextDirection.ltr,
child: Text(localizedText),
),
)
Language-Specific Pronunciation
Screen readers pronounce text based on language. Help them:
// Wrap foreign words with language hints
Semantics(
attributedLabel: AttributedString(
'Welcome to ${AppLocalizations.of(context)!.appName}',
attributes: [
// Mark app name as different language if needed
LocaleStringAttribute(
range: TextRange(start: 11, end: 11 + appName.length),
locale: Locale('en'), // App name is English
),
],
),
child: Text('Welcome to ${AppLocalizations.of(context)!.appName}'),
)
Number and Date Announcements
Use the intl package for proper formatting:
// Dates announced correctly for each locale
final formattedDate = DateFormat.yMMMMd(
Localizations.localeOf(context).toString()
).format(date);
Semantics(
label: AppLocalizations.of(context)!.publishedOn(formattedDate),
child: Text(formattedDate),
)
RTL Accessibility
Right-to-left languages need special attention:
Directional Icons
Some icons must flip for RTL:
Icon(
Icons.arrow_forward,
textDirection: Directionality.of(context),
)
// Or use directional icon variants
Icon(
Directionality.of(context) == TextDirection.rtl
? Icons.arrow_back
: Icons.arrow_forward,
)
Navigation Order
Screen readers navigate in reading order. Ensure widgets are ordered correctly:
// LTR: Back button → Title → Settings
// RTL: Settings → Title → Back button
Row(
textDirection: Directionality.of(context),
children: [
backButton,
Expanded(child: title),
settingsButton,
],
)
Focus Traversal
Control focus order for accessibility:
FocusTraversalGroup(
policy: ReadingOrderTraversalPolicy(),
child: Column(
children: [
TextField(semanticsLabel: l10n.emailField),
TextField(semanticsLabel: l10n.passwordField),
ElevatedButton(
onPressed: submit,
child: Text(l10n.loginButton),
),
],
),
)
Form Accessibility
Input Labels
Always provide translated labels and hints:
TextFormField(
decoration: InputDecoration(
labelText: AppLocalizations.of(context)!.emailLabel,
hintText: AppLocalizations.of(context)!.emailHint,
),
)
ARB file:
{
"emailLabel": "Email address",
"@emailLabel": {
"description": "Label for email input field"
},
"emailHint": "Enter your email address",
"@emailHint": {
"description": "Hint text for email input field"
}
}
Error Messages
Announce errors to screen readers:
TextFormField(
decoration: InputDecoration(
labelText: l10n.passwordLabel,
errorText: hasError ? l10n.passwordError : null,
),
validator: (value) {
if (value == null || value.length < 8) {
return l10n.passwordTooShort;
}
return null;
},
)
ARB with pluralization for errors:
{
"passwordTooShort": "Password must be at least 8 characters",
"remainingCharacters": "{count, plural, =1{1 more character needed} other{{count} more characters needed}}",
"@remainingCharacters": {
"placeholders": {
"count": {"type": "int"}
}
}
}
Form Announcements
Announce form state changes:
void _submitForm() async {
// Announce loading state
SemanticsService.announce(
l10n.submittingForm,
Directionality.of(context),
);
try {
await submitForm();
SemanticsService.announce(
l10n.formSubmittedSuccessfully,
Directionality.of(context),
);
} catch (e) {
SemanticsService.announce(
l10n.formSubmissionFailed,
Directionality.of(context),
);
}
}
Dynamic Content Announcements
Live Regions
Announce changes without moving focus:
// Counter that announces changes
Semantics(
liveRegion: true, // Announce changes
child: Text('${l10n.itemCount}: $count'),
)
Toast and Snackbar Messages
Ensure notifications are announced:
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.itemAddedToCart),
// SnackBars are automatically announced
),
);
// For custom toasts, announce manually
SemanticsService.announce(
l10n.itemAddedToCart,
Directionality.of(context),
);
Images and Media
Translated Alt Text
Image.network(
product.imageUrl,
semanticLabel: l10n.productImage(product.name),
)
ARB:
{
"productImage": "Image of {productName}",
"@productImage": {
"placeholders": {
"productName": {"type": "String"}
}
}
}
Complex Image Descriptions
For infographics or charts:
Semantics(
label: l10n.salesChartDescription(
highestMonth,
highestValue,
lowestMonth,
lowestValue,
),
child: SalesChart(data: salesData),
)
ARB:
{
"salesChartDescription": "Sales chart showing {highestMonth} had the highest sales at {highestValue}, and {lowestMonth} had the lowest at {lowestValue}",
"@salesChartDescription": {
"placeholders": {
"highestMonth": {"type": "String"},
"highestValue": {"type": "String"},
"lowestMonth": {"type": "String"},
"lowestValue": {"type": "String"}
}
}
}
Testing Accessibility with Localization
Automated Testing
testWidgets('login form is accessible in Spanish', (tester) async {
await tester.pumpWidget(
MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const LoginPage(),
),
);
// Check semantic labels exist
expect(
tester.getSemantics(find.byType(TextFormField).first),
matchesSemantics(label: 'Correo electrónico'),
);
// Check button is accessible
expect(
tester.getSemantics(find.byType(ElevatedButton)),
matchesSemantics(
label: 'Iniciar sesión',
isButton: true,
hasEnabledState: true,
isEnabled: true,
hasTapAction: true,
),
);
});
Manual Testing Checklist
For each supported language:
Screen Reader Testing:
- All buttons have translated labels
- Images have translated descriptions
- Forms announce errors in correct language
- Navigation order matches reading direction
- Live regions announce updates
Visual Testing:
- Text is readable at 200% zoom
- Color contrast meets WCAG AA (4.5:1)
- Touch targets are at least 48x48dp
- Translated text doesn't overflow
RTL Testing (Arabic, Hebrew, etc.):
- Layout mirrors correctly
- Icons flip appropriately
- Focus order is correct
- Swipe gestures work in correct direction
Common Accessibility Localization Mistakes
Mistake 1: Hardcoded Accessibility Labels
// Bad
Semantics(
label: 'Close dialog',
child: IconButton(...),
)
// Good
Semantics(
label: l10n.closeDialog,
child: IconButton(...),
)
Mistake 2: Concatenating Translated Strings for Screen Readers
// Bad - Word order varies by language
Semantics(
label: l10n.selected + ': ' + itemName,
child: ...,
)
// Good - Use placeholder
Semantics(
label: l10n.selectedItem(itemName), // "{item} selected" / "Seleccionado: {item}"
child: ...,
)
Mistake 3: Ignoring Pluralization in Announcements
// Bad
SemanticsService.announce(
'$count items in cart',
TextDirection.ltr,
);
// Good
SemanticsService.announce(
l10n.itemsInCart(count), // Uses plural rules
Directionality.of(context),
);
Mistake 4: Wrong Text Direction for Announcements
// Bad - Hardcoded direction
SemanticsService.announce(message, TextDirection.ltr);
// Good - Use current direction
SemanticsService.announce(
message,
Directionality.of(context),
);
Platform-Specific Considerations
iOS VoiceOver
// iOS-specific semantics
Semantics(
label: l10n.playButton,
hint: l10n.playButtonHint, // "Double tap to play"
onTapHint: l10n.plays, // Custom action hint
child: PlayButton(),
)
Android TalkBack
// Android-specific actions
Semantics(
label: l10n.listItem(itemName),
customSemanticsActions: {
CustomSemanticsAction(label: l10n.delete): () => deleteItem(),
CustomSemanticsAction(label: l10n.edit): () => editItem(),
},
child: ListItemWidget(),
)
Accessibility String Organization
Keep accessibility strings organized in ARB files:
{
"@@locale": "en",
"_ACCESSIBILITY_NAVIGATION": "=== Navigation ===",
"openMenu": "Open navigation menu",
"closeMenu": "Close navigation menu",
"goBack": "Go back",
"goForward": "Go forward",
"_ACCESSIBILITY_FORMS": "=== Forms ===",
"requiredField": "Required field",
"invalidInput": "Invalid input",
"formError": "Form has {count, plural, =1{1 error} other{{count} errors}}",
"_ACCESSIBILITY_ACTIONS": "=== Actions ===",
"loading": "Loading",
"refreshing": "Refreshing content",
"itemAdded": "{item} added",
"itemRemoved": "{item} removed",
"_ACCESSIBILITY_STATUS": "=== Status ===",
"online": "Online",
"offline": "Offline",
"connectionRestored": "Connection restored"
}
Summary
Building accessible multilingual apps requires:
- Translate all semantic labels - Every accessibility string needs translation
- Handle RTL properly - Text direction affects screen reader navigation
- Use proper announcements - SemanticsService for dynamic content
- Test in every language - Automated and manual testing
- Follow platform guidelines - iOS and Android have different patterns
Accessibility and localization aren't separate features—they're both about making your app work for everyone, everywhere.
Need help translating your Flutter app's accessibility strings? FlutterLocalisation handles ARB files with full support for descriptions and context. Try it free.