← Back to Blog

Flutter Localization and Accessibility: Building Inclusive Multilingual Apps

flutterlocalizationaccessibilitya11yscreen-readerrtl

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:

  1. Translate all semantic labels - Every accessibility string needs translation
  2. Handle RTL properly - Text direction affects screen reader navigation
  3. Use proper announcements - SemanticsService for dynamic content
  4. Test in every language - Automated and manual testing
  5. 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.