Semantics describes what a piece of UI means to assistive technology: a label, a role such as button or header, a value and the actions it supports. MergeSemantics folds a whole subtree into one node, and ExcludeSemantics removes a subtree from the tree.
The semantics tree: alongside the render tree Flutter builds a tree of nodes with labels, flags and actions; the engine hands it to TalkBack, VoiceOver or the browser's accessibility tree
Most widgets describe themselves: Text, buttons, TextField, Checkbox and Switch already add nodes; you need Semantics for custom widgets and missing labels
Built only on demand: Flutter skips the semantics pass until a screen reader, Switch Access or a test asks for it, so most users pay nothing for it
See it: showSemanticsDebugger: true on MaterialApp draws the nodes over the UI, debugDumpSemanticsTree() prints them
FlutterMiddle
Accessibility & Localization
Semantics, screen readers, text scaling, flutter_localizations, ARB, intl, plurals, RTL
Questions closed
0 / 21
- 0Review
- 2Asked often
Questions
Give it an accessible name: IconButton(tooltip: 'Search products') sends the tooltip to the screen reader as the button's name, and a bare Icon takes semanticLabel. An icon is only a font glyph, so without either the node has a role and an action but nothing to read.
Hand-rolled tappables: a GestureDetector adds a tap action but no role, so wrap it in Semantics(button: true, label: ...), or use IconButton, which brings the role, the tooltip and a 48 dp target
Name the action, not the picture: "More options", not "three dots", and leave out the word "button": the reader announces the role itself
Images: semanticLabel on an image that carries content, excludeFromSemantics: true on decoration
Hide decoration: ExcludeSemantics keeps a meaningless icon from becoming one more swipe stop
You write the strings into ARB files, flutter gen-l10n turns them into a typed AppLocalizations class, flutter_localizations supplies the translations of Material and Cupertino's own texts, and intl formats plurals, dates and numbers.
Setup: flutter_localizations (from the SDK) and intl in pubspec.yaml, generate: true under flutter:, and an l10n.yaml that names the ARB folder and the template file
ARB: JSON with one key per message; the template (app_en.arb) also carries @key metadata: a description for the translator and typed placeholders
Generated code: one getter or method per key, so a typo is a compile error; it is written into your source tree (for example lib/l10n), since the synthetic package:flutter_gen import has been removed
Wiring: MaterialApp(localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales)
The classic null: AppLocalizations.of(context) is an inherited lookup, so it returns null in the same build that creates MaterialApp; read it in a descendant
Material widgets give you most of it; your work is the custom parts: every control has a name and a role, targets are 48 dp, contrast passes, the layout survives 200% text, and the reading order makes sense with a screen reader on.
Names and roles: tooltip on icon buttons, semanticLabel on meaningful images, Semantics(button: true, ...) on custom tappables, header: true on section titles
Grouping: one node per card or list row (MergeSemantics or your own Semantics), ExcludeSemantics for decoration
Targets and contrast: 48x48 dp (44 pt on iOS); 4.5:1 for body text, 3:1 for large text and icons
Text scaling: no fixed heights around text, no maxLines: 1 on important labels, no app-wide clamp of the TextScaler
Changes nobody focused: a live region or a SnackBar for results that appear on their own; respect MediaQuery.disableAnimationsOf
Verify twice: meetsGuideline in widget tests for the mechanical rules, then a pass with TalkBack and VoiceOver, because only a person hears whether the order and the wording make sense
One focus stop that reads the label, role and state together, roughly "I agree to the terms, check box, not checked" (order and wording vary by TalkBack version and language). Without MergeSemantics there are two stops: a checkbox with no name and a text with no action.
Why the split hurts: the user hears "checkbox, not checked" with no idea what it agrees to, then a sentence that does nothing on double tap
Merging moves the action too: a double tap anywhere on the merged node toggles the checkbox, the same thing a label does on the web
Only semantics are merged: sighted users still have to hit the checkbox itself; CheckboxListTile merges the semantics and makes the whole row tappable
Never merge two actions: a merged row with an "Accept" and a "Reject" button leaves only one of them reachable
Give the card one semantics node of its own with a role, a hand-written label and the tap action, and drop the children's nodes: Semantics(container: true, button: true, label: ..., onTap: ..., excludeSemantics: true, child: card).
**Why onTap on the node**: excluding the children also drops the InkWell's tap action, so the node you create must carry it, or a double tap does nothing
**If the InkWell wraps the content**: Flutter already merges it into one node "Name 5 EUR" with a tap action but no button role; then you only add the role and your own label
**MergeSemantics instead: it joins the children's labels in tree order; fine for "Battery" + "87%", clumsy for a card where you want to write the sentence yourself
The three slots**: label is the name, value the part that changes, hint what acting does ("Opens product details"; the reader adds "double tap" itself)
Flags give the role: button, link, header, selected, checked; the role is what makes the reader say how to activate it
The trap: merging a card that holds two buttons ("Add to cart", "Favourite") collapses them into one action; keep those as separate nodes
The node has a name and a button role but no state: on and off are shown only by colour and the knob's position, so TalkBack reads the label and "button" either way, and after a double tap the user hears nothing that changed.
Why it happens: Semantics reports only what you set; button: true gives a role and GestureDetector adds the tap action, but nothing tells the OS the control is a switch or what its value is
The fix: toggled: value instead of button: true: the node gets the toggled-state flag, the reader announces it as a switch with "on" or "off", and every rebuild with a new value updates the node
Pick the right flag: toggled for switches, checked for checkboxes and radios, selected for tabs and chips; button stays for plain actions
Simplest fix: use Switch or SwitchListTile and restyle it; Material controls set these flags themselves
Catch it next time: the guidelines pass this widget (it is 48 dp and labelled), so assert the state: call final handle = tester.ensureSemantics(); first (without it getSemantics throws), then expect(tester.getSemantics(find.byType(SettingToggle)), containsSemantics(hasToggledState: true, isToggled: true)) and handle.dispose();
Mark the text that changes as a live region: Semantics(liveRegion: true, child: Text('$n results')). When that node's label changes, the reader speaks it without moving focus.
The node must stay in the tree: replacing it with a differently keyed widget creates a new node instead of changing one, and may not be announced
**SnackBar already does this**: its content is a live region; a hand-rolled toast in an Overlay is not
Nothing on screen to mark: SemanticsService.sendAnnouncement(View.of(context), message, textDirection); the old announce is deprecated since 3.35 because it cannot target a window
Android: check MediaQuery.supportsAnnounceOf(context): it is false there, because Android deprecated announcements (TalkBack clears its speech queue for them), so use a live region
Traps: a live region on a ticking timer never stops talking, and text that is also focused is heard twice
Geometry decides it, not the order you wrote the widgets in: the default ReadingOrderTraversalPolicy sorts focusable widgets by position under the current Directionality, row by row.
Two orders, two mechanisms: Tab and arrow keys follow the focus tree; TalkBack, VoiceOver and Switch Access follow the semantics tree, which is reordered with Semantics(sortKey: OrdinalSortKey(1)). Fixing one does not fix the other
Group first: a FocusTraversalGroup around a subtree visits everything inside before moving on: one per column, toolbar or dialog
Explicit order only when grouping cannot express it: OrderedTraversalPolicy plus FocusTraversalOrder(order: NumericFocusOrder(2), ...)
Where it breaks: a Stack or Wrap has no obvious reading order, and a custom dialog must move focus into itself on open and back on close
Material pads every control's hit area to 48x48 dp however small it looks, and takes colours from a ColorScheme whose pairs are designed to contrast. A GestureDetector around a 24 dp icon gets 24 dp and whatever colour you typed.
The numbers: 48x48 dp on Android, 44x44 pt on iOS, about 8 dp between neighbours; kMinInteractiveDimension is 48
Size the hit area, not the glyph: a SizedBox around the InkWell, with padding inside the tappable widget; padding outside it is not hit-tested
Check the theme: materialTapTargetSize: MaterialTapTargetSize.shrinkWrap or VisualDensity.compact set app-wide is often the line the audit found
Contrast is a pair: 4.5:1 for body text, 3:1 for large text and for icons and control borders; Colors.grey.shade400 on white is about 2:1
Usual offenders: disabled states, hints and placeholders, and text over a photo without a scrim
Flutter exposes the system accessibility switches on MediaQuery but honours few itself (Text bolds, a default AnimationController runs at 5% duration). Gate the rest (parallax, Lottie, video, auto-advancing carousels) with MediaQuery.disableAnimationsOf(context) and use a fade.
**disableAnimationsOf: iOS Reduce Motion and Android "Remove animations"; the motion flag that works on both platforms
iOS only**: highContrastOf, invertColorsOf and onOffSwitchLabelsOf are always false on Android, so never gate a feature on them alone
Also worth reading: accessibleNavigationOf (a screen reader is on; Switch Control too on iOS), boldTextOf, and textScalerOf for font size
What to change: no slides, parallax or zooms, and no carousels that advance by themselves; a shorter duration is not the same thing. Reduced motion means no large movement, not no feedback: a cross-fade is fine
High contrast needs no branch: pass highContrastTheme and highContrastDarkTheme to MaterialApp and it switches on iOS by itself
It shows "1 товар", "2 товаров", "5 товаров" and "21 товаров": two of four are wrong, because Russian picks among three forms by the last digits, not by "one or more". Move the whole phrase into an ARB message with an ICU plural.
Russian rules (CLDR): one for 1, 21, 101 but not 11 ("товар"), few for 2–4, 22–24 ("товара"), many for 0, 5–20, 25 ("товаров"), other for fractions
The fix: in app_ru.arb: "cartItems": "{count, plural, one{{count} товар} few{{count} товара} many{{count} товаров} other{{count} товара}}", with "count": {"type": "int"} in the template's @cartItems placeholders; the UI calls l10n.cartItems(n)
Categories belong to the language: the English template needs only one and other; each translation adds what its language needs, and other is always required. =0{Cart is empty} handles an exact number
**select**: the same syntax for gender or any enum: {gender, select, female{...} male{...} other{...}}
The root cause: grammar written in Dart is invisible to translators; a sentence glued from two keys has the same problem
Two shortcuts: formatting without a locale, and treating the phone's time zone as the event's. Pass the app's locale to every DateFormat and NumberFormat, use skeletons instead of fixed patterns, and convert event times with the event's own zone.
A skeleton, not a pattern: DateFormat.yMMMd(locale) asks for "year, short month, day" and lets the locale order it; DateFormat('dd/MM/yyyy') freezes one country's convention
Numbers carry a locale too: NumberFormat.currency(locale: 'de_DE', symbol: '€') gives 1.234,50 €; without a locale intl uses Intl.defaultLocale, which is en_US unless you set it
Use the app's locale: Localizations.localeOf(context), not the device's: they differ the day you ship an in-app language switch
**Outside a MaterialApp**: call initializeDateFormatting(locale) first, or DateFormat throws LocaleDataException
Time zones: toLocal() uses the phone's zone: right for "when did this happen", wrong for a flight or opening hours. Store UTC, send the event's IANA zone as a field, and convert with package:timezone
Flutter matches the user's ordered list of preferred locales against supportedLocales and falls back to the first supported one. An app stuck in English usually never passed supportedLocales (the default is only en_US), or on iOS never declared its languages in Info.plist.
Resolution order: a non-null MaterialApp.locale replaces the device's list with that one locale, but it goes through the same steps: localeListResolutionCallback or localeResolutionCallback if it returns non-null, then the default algorithm (exact match, then language/script/country matches, then supportedLocales.first). A locale missing from supportedLocales is silently replaced
The first supported locale: AppLocalizations.supportedLocales is generated alphabetically (de, en, ru), so the fallback here is German; return the template locale from localeResolutionCallback to choose it
iOS: list every language under CFBundleLocalizations in Info.plist; iOS passes the app only the preferred languages the bundle declares
In-app switch: keep a Locale? in app-wide state, null meaning "follow the system", and pass it to MaterialApp.locale; load the saved choice before runApp, or the first frame flashes the system language
Android 13+: declare android:localeConfig so the app also appears in the system's per-app language settings
The late final field reads AppLocalizations once, on the first build, and keeps that English instance for the life of the State. The locale change does rebuild the page, but the rebuild reads the cached object, so the strings stay English.
Why it happens: AppLocalizations.of(context) registers a dependency, so the page is told about the new locale; l10n is never re-evaluated, though, and switching locale keeps the State, it does not create a new one
Why the code looked fine: late defers the lookup to the first build, so it does not throw like the same line in initState would, and with one language nothing is wrong
The fix: look it up in build: the call is a cheap inherited-widget lookup, and each rebuild gets the current locale's strings
Same trap: late final theme = Theme.of(context) misses a dark-mode switch, and a NumberFormat or DateFormat built once in a field keeps the old locale
Catch it next time: a widget test that pumps the screen with Locale('en'), pumps again with Locale('de') and expects the German title; in review, treat any X.of(context) stored in a State field as a bug
Everything that reads the ambient Directionality mirrors itself; everything that names a physical side never does. Row, EdgeInsetsDirectional and TextAlign.start flip, while EdgeInsets.only(left:), Alignment.centerLeft and Positioned(left:) stay where you put them.
Swap one for one: EdgeInsetsDirectional.only(start:, end:), AlignmentDirectional.centerStart, PositionedDirectional(start:), BorderRadiusDirectional, TextAlign.start
Already directional: Row, MainAxisAlignment.start, a horizontal ListView: they mean "leading", not "left"
Icons: Material's directional icons such as Icons.arrow_back and Icons.chevron_left set matchTextDirection: true and flip themselves; a custom SVG does not, so mirror it with Transform.flip. Never mirror a clock, media controls, a logo or digits
The trap: a leftover Offset(8, 0) or Transform.translate: the layout mirrors, the badge does not. Check every screen under a forced TextDirection.rtl early, not after the Arabic release
Pump the widget under a real Localizations scope with an explicit locale, and assert against the same generated strings the UI uses. Then a test proves the screen is wired to translations instead of pinning English words.
Set up the test app: the generated localizationsDelegates and supportedLocales plus locale:; without the delegates AppLocalizations.of(context)! throws and the failure looks like a widget bug
Assert on the key, not the sentence: lookupAppLocalizations(const Locale('de')) gives the strings the app would show; find.text(l10n.saveButton) survives copy edits and fails when a widget hardcodes English
Cover locale-sensitive behaviour once each: a plural crossing from one to five, a date skeleton, one RTL layout
The system locale: tester.platformDispatcher.localesTestValue fakes the device's languages to test fallback; the fallback is supportedLocales.first, and the generated list is sorted alphabetically, so it is not the template language
Missing translations are a build check: untranslated-messages-file in l10n.yaml, failing CI when the file is anything other than {}; long German strings are a golden test on your densest screens
Translate at the presentation edge: the data layer turns the wire code into a typed failure, the widget turns that failure into text with AppLocalizations, and nothing in between holds display strings.
What breaks
A language switch: the state still holds the old sentence because nothing recomputes it: the screen changes language, the error does not
Layering: a Bloc or repository that translates needs a BuildContext or a locale passed in, and its tests assert on English prose
The shape that works
Wire: the server sends a stable code plus structured params: {"code": "INSUFFICIENT_FUNDS", "params": {"shortfall": 12.5}}
Data layer: maps codes to a sealed Failure hierarchy; an unknown code becomes UnknownFailure(code)
Widget: switches over the failure and calls the matching ARB message, formatting numbers with the current locale; a sealed type makes a new failure a compile error until it has a message
Unknown codes: a generic message that still shows the raw code, so support can work from a screenshot
Content the server owns
Catalogue names, CMS copy: send Accept-Language from the app's locale, not the device's, and key any disk cache by locale, or a language switch shows yesterday's language
A widget test proves the mechanical rules through meetsGuideline: tap-target size, text contrast and a label on every tappable node. Nothing automated tells you whether what the reader says makes sense, or in what order.
What a test can prove
Four shipped guidelines: androidTapTargetGuideline (48 dp), iOSTapTargetGuideline (44 pt), labeledTapTargetGuideline and textContrastGuideline (4.5:1), each run with await expectLater(tester, meetsGuideline(...))
Semantics are on by default: testWidgets has semanticsEnabled: true, so the tester.ensureSemantics() call in older examples is no longer needed; turning it off makes these checks meaningless
One node at a time: containsSemantics(tooltip: 'Archive', isButton: true, hasTapAction: true) against tester.getSemantics(finder); an IconButton's tooltip arrives as tooltip, not label. matchesSemantics is the strict version that fails on any unlisted flag
What the guidelines miss
Contrast over images: textContrastGuideline samples the rendered pixels around each text, so over a photo or a gradient its verdict is unreliable
Meaning and order: a reading order that is legal but absurd, "archive button button", a dialog that never moves focus into itself, a live region that talks too much
On a device
TalkBack and VoiceOver: swipe through the new screen once, eyes closed; Android Accessibility Scanner and Xcode's Accessibility Inspector both read a Flutter app's real semantics
While developing: showSemanticsDebugger: true or debugDumpSemanticsTree()
A canvas produces pixels and the accessibility bridge only sees semantics nodes, so a CustomPaint is silent unless you describe it: one summary Semantics label around it, or per-region nodes from CustomPainter.semanticsBuilder.
One summary node
Usually the best answer: Semantics(label: 'Revenue, 12 months, from 3.2 thousand in January to 9.8 thousand in December', child: ExcludeSemantics(child: chart)); one sentence beats twelve swipe stops
semanticsBuilder
Per-region nodes: return one CustomPainterSemantics per bar, each with a non-empty rect in the painter's coordinates; a label also needs a textDirection
**shouldRebuildSemantics**: by default it returns shouldRepaint; override it when the labels depend on data the repaint check ignores, or they freeze while the pixels update
**Text drawn with TextPainter**: invisible for the same reason, so an axis label painted on a canvas is not a label
A custom RenderObject
**describeSemanticsConfiguration**: set isSemanticBoundary, label, value, textDirection and actions such as onTap or onIncrease
Refresh it: call markNeedsSemanticsUpdate() whenever that data changes; markNeedsPaint() does not rebuild semantics
One English ARB is the source of truth, CI syncs it with the translation platform, the build fails on missing translations, and pseudo-localization finds layout damage before any translator does.
The pipeline
The source file: app_en.arb with a description and placeholder metadata on every message; a key reused for a noun and a verb comes back wrong in half the languages
Translated ARBs are generated files: CI uploads the source on merge and opens a PR with what comes back; nobody edits them by hand
Gate the build: untranslated-messages-file in l10n.yaml, and the release job fails when it lists a message for a shipping locale; broken ICU syntax already fails flutter gen-l10n
Pseudo-localization
A fake debug-only locale: accented letters, brackets around each message and about 40% padding, generated from app_en.arb by a small script
What one pass finds: hardcoded strings (no accents), clipped text (no closing bracket) and sentences glued from fragments (several bracket pairs)
What breaks first
German and Russian: strings run 30–40% longer, so fixed-width buttons and maxLines: 1 labels overflow
Japanese and Chinese: line breaks fall between almost any characters, and fonts need fallbacks the default may not cover
Arabic and Hebrew: RTL mirroring, plus taller glyphs that a hardcoded height: on TextStyle clips