Accessibility: Semantics, Screen Readers & Focus
Semantics widget, TalkBack/VoiceOver testing, focus order, WCAG compliance in Flutter
Open interactive version (quiz + challenge)Real-world analogy
What is it?
Accessibility ensures your app is usable by people with disabilities — visual impairments, motor limitations, cognitive differences. It is also legally required in many jurisdictions (ADA in the US, EAA in the EU) for apps in public-facing sectors. Senior Flutter engineers are expected to design for and test accessibility from the start.
Real-world relevance
The NGO survey app for field workers: survey questions must be completable via TalkBack for workers with low vision. Each question's Semantics label includes the question number and type. Radio buttons use MergeSemantics to combine the question text with the answer. Decorative country flag images use ExcludeSemantics. Form submit button has a minimum 48x48 tap target enforced by SizedBox.
Key points
- The Semantics Widget — Semantics() wraps any widget to provide accessibility metadata: label, hint, value, button, header, enabled, onTap, onLongPress. The screen reader reads these instead of (or in addition to) the visual content.
- Automatic Semantics — Most Flutter material widgets automatically provide semantics: Text provides its content, ElevatedButton says 'button', Checkbox says checked/unchecked state. Custom widgets built from Container/GestureDetector have NO automatic semantics — you must add them manually.
- MergeSemantics — MergeSemantics combines its children's semantic nodes into a single node. Use it when multiple widgets together form one logical accessible element (e.g., an icon + label row that should be announced as one item).
- ExcludeSemantics — ExcludeSemantics hides a subtree from the accessibility tree entirely. Use for purely decorative elements (background images, dividers, loading spinners that accompany text descriptions).
- Semantic Actions — Semantics can declare custom actions: onTap, onLongPress, onScrollUp, onScrollDown, onIncrease, onDecrease. Sliders and custom scrollable widgets need these to be usable without touch.
- Focus Management — FocusNode and FocusScope control keyboard/directional navigation focus. FocusOrder (NumericFocusOrder, LexicalFocusOrder) controls tab order. For screen readers, the visual order typically matches the semantic tree order — verify with TalkBack.
- Tap Target Size — WCAG 2.5.5 requires a minimum 44x44 CSS px tap target. Android Material guidelines recommend 48x48 dp. Use GestureDetector or InkWell with a minimum size constraint, or the SizedBox wrapper pattern. Small icons must be wrapped.
- TalkBack & VoiceOver Testing — Test on real devices: enable TalkBack (Android) via Settings > Accessibility. Enable VoiceOver (iOS) via Settings > Accessibility. Use the Flutter accessibility inspector in DevTools. Never ship a release without TalkBack testing of critical user flows.
- WCAG Compliance Basics — WCAG 2.1 Level AA is the standard for most apps: Perceivable (text alternatives for non-text content), Operable (keyboard accessible, no seizure-inducing content), Understandable (readable, predictable), Robust (compatible with assistive tech).
- Colour Contrast — WCAG AA requires 4.5:1 contrast ratio for normal text, 3:1 for large text and UI components. Use the Flutter DevTools accessibility checker or browser-based contrast checkers. The default accent colour must be checked against backgrounds.
Code example
// === CUSTOM ACCESSIBLE CARD ===
class ClaimCard extends StatelessWidget {
final Claim claim;
final VoidCallback onTap;
const ClaimCard({required this.claim, required this.onTap, super.key});
@override
Widget build(BuildContext context) {
return Semantics(
label: 'Claim ${claim.id}',
hint: 'Double tap to view claim details',
button: true,
enabled: true,
child: GestureDetector(
onTap: onTap,
child: MergeSemantics(
// Merges icon + status text into one announcement
child: Row(children: [
ExcludeSemantics(
child: Icon(Icons.receipt, color: Theme.of(context).colorScheme.primary),
),
const SizedBox(width: 12),
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Claim #${claim.id}',
style: Theme.of(context).textTheme.titleMedium),
Text(claim.status,
style: Theme.of(context).textTheme.bodySmall),
]),
]),
),
),
);
}
}
// === TAP TARGET SIZE ENFORCEMENT ===
class AccessibleIconButton extends StatelessWidget {
final IconData icon;
final VoidCallback onPressed;
final String semanticLabel;
const AccessibleIconButton({
required this.icon, required this.onPressed,
required this.semanticLabel, super.key,
});
@override
Widget build(BuildContext context) {
return Semantics(
label: semanticLabel,
button: true,
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(24),
child: const SizedBox(
width: 48, height: 48, // WCAG minimum tap target
child: Center(child: Icon(icon)),
),
),
);
}
}
// === FOCUS ORDER CONTROL ===
class SurveyForm extends StatefulWidget {
@override
State<SurveyForm> createState() => _SurveyFormState();
}
class _SurveyFormState extends State<SurveyForm> {
final _nameFocus = FocusNode();
final _emailFocus = FocusNode();
final _submitFocus = FocusNode();
@override
void dispose() {
_nameFocus.dispose(); _emailFocus.dispose(); _submitFocus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(children: [
TextFormField(
focusNode: _nameFocus,
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) => _emailFocus.requestFocus(),
decoration: const InputDecoration(labelText: 'Full name'),
),
TextFormField(
focusNode: _emailFocus,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _submitFocus.requestFocus(),
decoration: const InputDecoration(labelText: 'Email address'),
),
Semantics(
focusable: true,
child: Focus(
focusNode: _submitFocus,
child: ElevatedButton(
onPressed: _submit,
child: const Text('Submit survey'),
),
),
),
]);
}
}
// === FLUTTER TEST — ACCESSIBILITY ===
testWidgets('ClaimCard announces correct semantics', (tester) async {
await tester.pumpWidget(MaterialApp(
home: ClaimCard(claim: mockClaim, onTap: () {}),
));
expect(
tester.getSemantics(find.byType(ClaimCard)),
matchesSemantics(label: 'Claim 123', isButton: true, hasTapAction: true),
);
});Line-by-line walkthrough
- 1. Semantics(label: 'Claim 123', hint: 'Double tap to view', button: true) — provides all three key screen reader properties: what it is, what it does, and its role
- 2. ExcludeSemantics on the receipt icon — prevents the screen reader from announcing 'image' before the meaningful text content
- 3. MergeSemantics — makes the entire Row (icon + title + status) announce as one node instead of three separate announcements
- 4. SizedBox(width: 48, height: 48) — enforces minimum tap target size around small icons
- 5. FocusNode + textInputAction.next — chains keyboard focus between form fields in the correct logical order
- 6. onFieldSubmitted: (_) => _emailFocus.requestFocus() — programmatically moves focus when user presses the keyboard's next action
- 7. matchesSemantics matcher in widget test — verifies the semantic tree node, not the visual widget
- 8. tester.getSemantics(find.byType(ClaimCard)) — retrieves the SemanticsNode for the widget under test
Spot the bug
class StatusBadge extends StatelessWidget {
final String status;
const StatusBadge({required this.status, super.key});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: status == 'approved' ? Colors.green : Colors.red,
borderRadius: BorderRadius.circular(12),
),
child: Text(status, style: const TextStyle(color: Colors.white)),
);
}
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Flutter accessibility docs (Flutter Docs)
- Semantics class API (Flutter Docs)
- WCAG 2.1 guidelines (W3C)
- Android Accessibility — TalkBack (Google Support)
- Testing Flutter accessibility (Flutter Docs)