Accessibility used to arrive at the end of a Flutter project as a one-line ticket: "add screen reader support". That stopped working. Since the European Accessibility Act obligations began applying in June 2025, consumer-facing mobile apps sold into the EU — banking, e-commerce, ticketing, transport, e-books — are expected to meet EN 301 549, which points at WCAG 2.1 AA. US procurement has wanted a VPAT for years. Increasingly, the accessibility questionnaire arrives before the contract does.
The good news for Flutter teams: because Flutter draws its own pixels, the accessibility layer is explicit and testable. Nothing is implicit, which means nothing is accidentally right — but also nothing is out of reach. This tutorial covers the semantics model, the twelve issues we find in almost every audit, and how to put accessibility checks in CI so the app does not regress the week after you fix it.
How Flutter accessibility actually works
Flutter builds a semantics tree alongside the widget tree and hands it to the platform: TalkBack via Android's AccessibilityNodeProvider, VoiceOver via UIKit accessibility on iOS. Most Material and Cupertino widgets already emit semantics nodes. Your custom widgets — the GestureDetector on a Container that everyone writes — emit nothing at all.
Turn on the debugger and look at what you are shipping:
// main.dart, debug builds only
import 'package:flutter/rendering.dart';
void main() {
debugSemanticsEnabled = false; // flip to true to inspect
runApp(const MyApp());
}
Or use MaterialApp(showSemanticsDebugger: true), and DevTools' inspector, which has a semantics view showing labels, flags, and actions per node. If a control shows no node, the screen reader cannot see it.
The twelve findings that come up in nearly every audit
1. Tappable containers with no semantics
// Before: invisible to TalkBack
GestureDetector(onTap: _archive, child: Icon(Icons.archive))
// After
Semantics(
button: true,
label: 'Archive conversation',
child: GestureDetector(onTap: _archive, child: const Icon(Icons.archive)),
)
Better still, use IconButton with tooltip: — it wires up the semantics, the button flag, and the minimum tap target for you.
2. Icon-only buttons with no label
Icon(Icons.more_vert) announces nothing useful. Always pass a tooltip on IconButton, or semanticLabel on Icon and Image. Decorative images should be excluded, not labelled:
ExcludeSemantics(child: Image.asset('assets/hero_swirl.png'));
3. Tap targets under 48dp
WCAG 2.1 AA (2.5.5 at AAA, but 24×24 minimum at AA via 2.5.8) plus both platform guidelines land on 48×48dp as the practical target. Dense list rows and small close buttons are the usual offenders.
IconButton(
constraints: const BoxConstraints(minWidth: 48, minHeight: 48),
padding: EdgeInsets.zero,
onPressed: _close,
tooltip: 'Close',
icon: const Icon(Icons.close),
)
4. Colour contrast below 4.5:1
Brand greys on white are the classic failure: #9E9E9E on white is 2.8:1. Check every text style in the theme once, fix it at the ColorScheme level, and it stops recurring. Also avoid colour as the only signal — an error field needs an icon or text, not just a red border.
5. Text that will not scale
Users routinely run 150–200% text scale. Two rules: never set a fixed height on a widget that contains text, and never disable scaling globally.
// Anti-pattern seen in the wild — do not do this
MediaQuery.withNoTextScaling(child: MyApp());
Instead, clamp sensibly at the top of the tree and let layouts wrap:
MaterialApp(
builder: (context, child) {
final mq = MediaQuery.of(context);
return MediaQuery(
data: mq.copyWith(
textScaler: mq.textScaler.clamp(minScaleFactor: 1.0, maxScaleFactor: 2.0),
),
child: child!,
);
},
);
Then test at 200%: Rows become Wraps, fixed-height buttons become IntrinsicHeight or padded, and Text gets softWrap rather than overflow: TextOverflow.ellipsis where content matters.
6. Form fields without labels or error announcements
TextField with only hintText is unlabelled once the user types. Use labelText, and make validation errors reach the screen reader:
TextFormField(
decoration: const InputDecoration(
labelText: 'Email address',
errorText: null, // supplied by validator
),
validator: (v) => v!.contains('@') ? null : 'Enter a valid email address',
);
// Announce asynchronous / form-level errors explicitly
SemanticsService.announce(
'Payment failed. Card declined.',
Directionality.of(context),
assertiveness: Assertiveness.assertive,
);
7. Compound rows that read as five separate nodes
A list tile that announces "Jane Doe", "Invoice", "£240", "Overdue", "chevron" is exhausting. Merge it:
MergeSemantics(
child: Semantics(
button: true,
label: 'Invoice for Jane Doe, £240, overdue',
child: ListTile(/* ... */),
),
)
Use Semantics(container: true, ...) when you want a grouping node without flattening children.
8. Reading order that follows layout, not meaning
Stacks, overlays, and custom multi-child layouts can produce a traversal order that jumps around. Fix it with explicit ordering:
Semantics(
sortKey: const OrdinalSortKey(1),
child: header,
);
Semantics(
sortKey: const OrdinalSortKey(2),
child: body,
);
9. Modals that do not trap focus
When a dialog or bottom sheet opens, content behind it must become invisible to the screen reader. showDialog handles the common case; hand-rolled overlays and custom sheets usually do not.
ExcludeSemantics(
excluding: _sheetIsOpen,
child: pageBehind,
);
Also give the sheet a Semantics(namesRoute: true, label: 'Filter options') header so the route is announced on open.
10. Live regions that never announce
Snackbars, toasts, countdowns, and "3 items added" badges are silent unless marked:
Semantics(liveRegion: true, child: Text(statusMessage));
11. Animations that ignore reduce-motion
Vestibular disorders are covered by WCAG 2.3.3. Respect the OS switch:
final reduceMotion = MediaQuery.disableAnimationsOf(context);
final duration = reduceMotion ? Duration.zero : const Duration(milliseconds: 300);
Parallax headers, auto-playing carousels, and hero transitions all need this branch.
12. Custom sliders, charts, and gesture surfaces
Anything with a drag gesture needs semantic actions so it can be driven without dragging:
Semantics(
slider: true,
label: 'Loan term',
value: '$years years',
increasedValue: '${years + 1} years',
decreasedValue: '${years - 1} years',
onIncrease: () => setState(() => years++),
onDecrease: () => setState(() => years--),
child: CustomPaint(/* ... */),
);
For charts, provide a data table or textual summary behind an accessible toggle. "Chart" as a label is a finding, not a fix.
Automating the checks
Manual audits do not scale across sprints. Flutter ships accessibility guidelines you can assert in widget tests, and they catch findings 3, 4, 6 and part of 1 automatically.
// test/accessibility_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('checkout screen meets accessibility guidelines', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(const MaterialApp(home: CheckoutScreen()));
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
await expectLater(tester, meetsGuideline(iOSTapTargetGuideline));
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
await expectLater(tester, meetsGuideline(textContrastGuideline));
handle.dispose();
});
}
Add a semantics snapshot for your most important screens so label changes are reviewed rather than discovered:
testWidgets('invoice row semantics', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(const MaterialApp(home: InvoiceRow()));
expect(
tester.getSemantics(find.byType(InvoiceRow)),
matchesSemantics(
isButton: true,
hasTapAction: true,
label: 'Invoice for Jane Doe, £240, overdue',
),
);
handle.dispose();
});
Run it on every pull request:
# .github/workflows/ci.yml (excerpt)
- run: flutter test test/accessibility_test.dart --reporter expanded
Large text is worth a dedicated golden run:
tester.platformDispatcher.textScaleFactorTestValue = 2.0;
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
If a golden overflows at 200%, the layout is broken for a real user, not just for the test.
What automation cannot tell you
Automated guidelines catch roughly a third of real findings. The rest need twenty minutes on a device:
- Android: enable TalkBack, then swipe right through every screen. Can you complete the primary task — sign in, search, buy — without looking at the screen?
- iOS: enable VoiceOver, use the rotor to jump by heading and by control. Are there headings at all?
- Turn on Large Text (iOS) / Font size: largest + Display size: largest (Android) and re-run the same flow.
- Turn on Reduce Motion and Bold Text and confirm nothing disappears or clips.
- Try the app with an external keyboard on both platforms — Tab order and visible focus rings are part of WCAG 2.1 AA, and Flutter's
FocusTraversalGroupis how you control them.
Write down what you did. An audit trail of manual test passes is exactly what an EAA conformance statement or a VPAT needs.
A pragmatic remediation order
When we inherit an app with a failed audit, we sequence the work like this:
- Blockers first: unlabelled controls on the primary revenue path (sign-in, search, cart, checkout). These stop a user from completing the task at all.
- Contrast and text scale in the theme — one change, app-wide effect.
- Tap targets in shared components — again, small change, wide reach.
- Grouping and reading order on list-heavy screens, which is where the cognitive load is worst.
- Live regions, focus trapping, reduce-motion — the polish items that separate "technically compliant" from usable.
- CI guardrails last, so the fixes hold.
Doing it in this order means the app is usable in week one, even though the conformance report takes a few sprints.
Definition of done
Before you call an accessibility workstream complete:
- Every interactive element has a role and a label.
- Primary flows are completable with TalkBack and with VoiceOver, verified on device.
- No layout breaks at 200% text scale.
- All body text meets 4.5:1 contrast; large text meets 3:1.
- Animations respect reduce-motion.
meetsGuidelinechecks run on every pull request.- Manual test results and known exceptions are documented in a conformance statement.
Accessibility work is unusually well-behaved as engineering goes: the fixes are small, local, and permanent, and most of them improve the app for everyone. The expensive part is retrofitting it into a design system that never allowed for it — which is why it belongs in the component library, not in a compliance ticket.
AviaryApps supplies senior Flutter consultants for accessibility audits, WCAG 2.1 AA remediation, and design-system work — as a delivery team or alongside your own developers. Get in touch to talk through scope.