Accessibility & Localization
Irbisa · cheatsheetSeptember 13, 2026

Accessibility & Localization

Semantics, screen readers, text scaling, flutter_localizations, ARB, intl, plurals, RTL

Middle Developer16 itemscompressed for a skim
  1. 01

    TalkBack lands on the icon in your app bar and says only "button" — what is Flutter reporting, and what is the smallest fix?

    Easy

    An Icon is a glyph from a font with no text in it, so the semantics node gets a button role and a tap action but no name for the screen reader to read.

    // ❌ announced as "button" — the user has no idea what it does
    AppBar(
      actions: [
        IconButton(icon: const Icon(Icons.search), onPressed: _openSearch),
      ],
    );
    …
  2. 02

    You wire up your first ARB file and AppLocalizations.of(context) comes back null at runtime — what is missing?

    Easy

    AppLocalizations.of is an InheritedWidget lookup, so it finds nothing unless the delegate is registered strictly above the context you handed it.

    // l10n.yaml (project root)
    //   arb-dir: lib/l10n
    //   template-arb-file: app_en.arb
    //   output-localization-file: app_localizations.dart
    //   nullable-getter: false
    //
    …
  3. 03

    A product card with a title, a price and a tap target is announced as three separate stops — how do you make a screen reader treat it as one button?

    Medium

    Give the card one semantics node of its own with a role and a hand-written label, and stop the children from contributing nodes underneath it.

    // ❌ three stops: title, price, and an unnamed tappable region
    GestureDetector(
      onTap: _open,
      child: Column(children: [Text(name), Text('$price EUR'), const Icon(Icons.chevron_right)]),
    );
    …
  4. 04

    A filter changes the result count but a screen reader stays silent — how do you announce a change that has no widget of its own?

    Medium

    Mark the widget whose text changed as a live region; the direct announcement API is a last resort, and Android now actively discourages it.

    // ✅ status text the screen reader re-reads whenever it changes
    class ResultCount extends StatelessWidget {
      const ResultCount(this.count, {super.key});
      final int count;
    
      @override
    …
  5. 05

    Tab and switch control jump around your form in an order that makes no sense, though it looks fine on screen — what decides that order?

    Medium

    Geometry decides it, not the order you wrote the widgets in: the default ReadingOrderTraversalPolicy sorts focusable nodes by position under the current Directionality, so any layout that is not a clean reading order traverses badly.

    // ❌ two visual columns; reading-order traversal zigzags left-right-left-right
    Row(children: [
      Column(children: [TextField(controller: firstName), TextField(controller: lastName)]),
      Column(children: [TextField(controller: street), TextField(controller: city)]),
    ]);
    …
  6. 06

    An audit flags your custom chip row for tap target size and contrast but passes the Material buttons beside it — what is Material doing that your widget is not?

    Medium

    Material pads a control's hit area out to 48x48 independently of how big it looks, and takes its colours from a ColorScheme whose pairs are contrast-checked; a GestureDetector around a 24 dp icon gets exactly 24 dp and whatever colour you typed.

    // ❌ 24 dp target, and the padding is outside the gesture detector
    Padding(
      padding: const EdgeInsets.all(12),
      child: GestureDetector(
        onTap: _remove,
        child: const Icon(Icons.close, size: 24, color: Color(0xFFBDBDBD)), // ~2:1
    …
  7. 07

    A user turns on Reduce Motion and your onboarding still slides and parallaxes — which MediaQuery flags exist, and what should the UI actually change?

    Medium

    Flutter surfaces the platform's accessibility switches on MediaQueryData, but nothing reads them for you — the single exception is Text, which merges in FontWeight.bold on its own when bold text is requested.

    class Reveal extends StatelessWidget {
      const Reveal({super.key, required this.child, required this.animation});
      final Widget child;
      final Animation<double> animation;
    
      @override
    …
  8. 08

    The Russian build renders "5 товар" instead of "5 товаров" — what is wrong with that message, and how do you write it so a translator can fix it?

    Medium

    The sentence was assembled in Dart by gluing a number onto a noun, so no translator ever saw the whole message and there was nowhere to express Russian's plural rules; plural selection belongs inside the ARB message as ICU.

    // lib/l10n/app_en.arb
    // {
    //   "@@locale": "en",
    //   "cartItems": "{count, plural, =0{Cart is empty} one{{count} item} other{{count} items}}",
    //   "@cartItems": {
    //     "description": "Item count shown in the cart header",
    …
  9. 09

    A German user sees a price as $1,234.50 and a date as 12/03/2026, and a scheduled event shows the wrong hour — what did the formatting code get wrong?

    Medium

    Both bugs come from the same shortcut: formatting without a locale, and treating the device's timezone as the user's.

    // ❌ pattern and separators frozen to one country, zone taken from the handset
    final when = DateTime.parse(json['startsAt'] as String).toLocal();
    Text('${DateFormat('dd/MM/yyyy HH:mm').format(when)}  '
         '${NumberFormat('#,##0.00').format(price)} EUR');
    
    // ✅ skeletons + the app's locale, which is not always the device's
    …
  10. 10

    The app comes up in English on a Spanish phone even though es.arb exists — how does Flutter pick a locale, and where does an in-app language switch hook in?

    Medium

    Flutter matches the user's ordered list of preferred locales against supportedLocales, and an app that is stubbornly English almost always never got supportedLocales and localizationsDelegates at all — WidgetsApp then defaults to [Locale('en', 'US')].

    final localeStore = ValueNotifier<Locale?>(null);   // null = follow the system
    
    Future<void> main() async {
      WidgetsFlutterBinding.ensureInitialized();
      final prefs = await SharedPreferences.getInstance();
      final saved = prefs.getString('locale');
    …
  11. 11

    In Arabic your row's icon stays on the left and the padding sits on the wrong side — what mirrors automatically in Flutter and what never will?

    Medium

    Everything that reads the ambient Directionality mirrors itself, and everything that names a physical side does not — so Row and EdgeInsetsDirectional flip while EdgeInsets.only(left:) and Positioned(left:) stay exactly where you put them.

    // ❌ four physical sides — identical in Arabic, and wrong in all four places
    Padding(
      padding: const EdgeInsets.only(left: 16, right: 8),
      child: Stack(children: [
        Align(alignment: Alignment.centerLeft, child: Text(name)),
        Positioned(left: 0, child: Icon(Icons.chevron_left)),
    …
  12. 12

    Your widget tests assert on find.text('Save') and a German screen still shipped untranslated — how should localized UI actually be tested?

    Medium

    Pump the widget inside a real Localizations scope and assert against the same generated getter the UI calls, so the test proves the wiring instead of pinning one language's copy.

    import 'package:flutter/material.dart';
    import 'package:flutter_test/flutter_test.dart';
    import 'package:myapp/l10n/app_localizations.dart';
    
    Widget wrap(Widget child, Locale locale) => MaterialApp(
          locale: locale,                                        // pins this test's language
    …
  13. 13

    The backend returns {"code": "INSUFFICIENT_FUNDS"} and your Bloc stores the sentence it will show — what breaks, and where does translation belong?

    Hard

    Translation belongs at the presentation edge: the data layer turns the wire code into a typed failure, the widget turns that failure into a string with AppLocalizations, and nothing in between ever holds display text.

    // Data layer: wire code -> typed failure. Params stay structured.
    Failure mapFailure(Map<String, dynamic> json) {
      final params = (json['params'] as Map<String, dynamic>?) ?? const {};
      return switch (json['code'] as String) {
        'INSUFFICIENT_FUNDS' => InsufficientFunds(params['shortfall'] as String),
        'SESSION_EXPIRED' => const SessionExpired(),
    …
  14. 14

    A PR adds an icon-only toolbar — what can a widget test actually prove about its accessibility, and what still needs a device with TalkBack on?

    Hard

    Widget tests can prove the mechanical rules — tap-target size, contrast, that every tappable node carries a label — through meetsGuideline, but nothing automated can tell you whether what the screen reader says makes sense in the order it says it.

    testWidgets('the editor toolbar meets the mechanical guidelines', (tester) async {
      final handle = tester.ensureSemantics();   // no client asked -> no semantics tree at all
      addTearDown(handle.dispose);
    
      await tester.pumpWidget(const MaterialApp(home: EditorPage()));
    …
  15. 15

    TalkBack reads your revenue chart as one empty box — how do you give a CustomPainter or a custom RenderObject real semantics?

    Hard

    A CustomPaint contributes nothing to the semantics tree unless you supply semanticsBuilder, because a canvas produces pixels and the accessibility bridge only ships nodes.

    class SparklinePainter extends CustomPainter {
      SparklinePainter(this.points, this.labels);
      final List<double> points;
      final List<String> labels;
    
      @override
    …
  16. 16

    You are adding nineteen languages to an app that has one — what goes into CI, and what will break first in German and Japanese?

    Hard

    Make one English ARB the single source of truth, move it to and from a translation system through CI, gate the build on completeness — and find the layout damage with pseudo-localization long before a translator sees the app.

    // tool/pseudo_loc.dart — run in CI before `flutter gen-l10n` on debug builds.
    // Turns app_en.arb into a locale that exposes overflow and hardcoded strings.
    import 'dart:convert';
    import 'dart:io';
    
    const _accents = {
    …