Accessibility (a11y)
4 min read
UX & Compliance
Most Material widgets are accessible by default — the real work is not breaking that with custom widgets, and explicitly labelling icons, images, and custom interactions.
| Dimension | What to verify | Tools |
|---|---|---|
| Screen reader | TalkBack (Android) / VoiceOver (iOS) announce meaningfully | Semantics widget, SemanticsDebugger |
| Contrast | Text vs background ≥ 4.5:1 (AA), 7:1 (AAA) | WebAIM contrast checker, design review |
| Touch targets | Tappable area ≥ 48×48 dp | kMinInteractiveDimension (= 48) |
| Text scaling | Layout doesn't break at 200% font size | Device settings → text size, MediaQuery.textScalerOf |
| Reduced motion | Animations shorten/disable when OS preference is set | MediaQuery.disableAnimationsOf(context) |
| Focus / keyboard | Tab + arrow keys navigate; focused element visible | FocusableActionDetector, Shortcuts, Actions |
Code in action
// Semantic label on an icon button (icons alone aren't meaningful to screen readers)
IconButton(
icon: const Icon(Icons.delete),
onPressed: deleteItem,
tooltip: 'Delete', // also acts as semantic label
);
// Or explicitly via Semantics
Semantics(
label: 'Delete item',
hint: 'Double tap to delete',
button: true,
child: IconButton(icon: const Icon(Icons.delete), onPressed: deleteItem),
);
// Describe images
Semantics(
image: true,
label: 'Profile photo of Alice',
child: CircleAvatar(backgroundImage: NetworkImage(url)),
);
// Skip purely decorative widgets
Semantics(
excludeSemantics: true,
child: Image.asset('background_pattern.png'),
);
// Group related semantics
MergeSemantics(
child: Row(children: [
const Icon(Icons.star),
const SizedBox(width: 4),
const Text('Featured'),
]),
);
// Screen reader: "Featured" (announced once, not "Star, Featured")
// Respect "reduce motion"
final reduced = MediaQuery.disableAnimationsOf(context);
return AnimatedContainer(
duration: reduced ? Duration.zero : const Duration(milliseconds: 300),
width: _expanded ? 200 : 100,
);
// Text scales by default — don't fight it
Text('Hello', style: Theme.of(context).textTheme.bodyLarge);
// ❌ textScaler: TextScaler.linear(1.0) // breaks accessibility
// Touch targets — ensure 48×48dp minimum
SizedBox(
width: kMinInteractiveDimension,
height: kMinInteractiveDimension,
child: IconButton(icon: const Icon(Icons.close), onPressed: () {}),
);
Focus and keyboard navigation
// Make a custom widget focusable + keyboard-actionable
FocusableActionDetector(
shortcuts: const {
SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(),
SingleActivator(LogicalKeyboardKey.space): ActivateIntent(),
},
actions: {
ActivateIntent: CallbackAction(onInvoke: (_) => onTap()),
},
child: const Material(
child: InkWell(child: Padding(padding: EdgeInsets.all(16),
child: Text('Custom button'))),
),
);
Material widgets (ElevatedButton, IconButton, etc.) handle this for free. Custom interactive widgets need to opt in.
Testing accessibility
testWidgets('delete button has correct semantics', (tester) async {
await tester.pumpWidget(MyWidget());
final s = tester.getSemantics(find.byType(IconButton));
expect(s.label, 'Delete item');
expect(s.hasAction(SemanticsAction.tap), true);
});
testWidgets('respects reduced motion', (tester) async {
tester.platformDispatcher.accessibilityFeaturesTestValue =
const FakeAccessibilityFeatures(disableAnimations: true);
// assert animation duration is zero
});
| Tool | Use |
|---|---|
SemanticsDebugger widget | Wrap your app to visually see semantics tree |
flutter test + tester.getSemantics(...) | Unit-level a11y assertions |
| Android Accessibility Scanner | Catches low contrast, small targets, unlabelled controls |
| Xcode Accessibility Inspector | iOS equivalent, with audit + live navigation |
accessibility_audit CI step | flutter analyze with custom lint rules |
Common mistakes to avoid
❌ IconButton with no label / tooltip
Screen reader announces "Button. Double tap to activate." — useless.
✅ Always provide tooltip or Semantics label.
❌ Clamping textScaleFactor to 1.0
"It breaks our layout at 200%" → fix the layout, don't break the feature.
✅ Make layouts elastic; test at 1.5x and 2x.
❌ Color as the only state indicator
"Required fields are red" — colorblind users can't tell.
✅ Pair color with icon or text.
❌ Tap targets smaller than 48×48dp
Easy to mis-tap; accessibility scanners fail.
✅ kMinInteractiveDimension or wrap small icons in Padding.
❌ Animations that don't respect reduce-motion
Some users (vestibular disorders) get sick from parallax / heavy motion.
✅ Check MediaQuery.disableAnimationsOf(context).
❌ Custom gestures without keyboard equivalents
Swipe-to-delete with no alternative → keyboard / switch users stuck.
✅ Provide an action menu or keyboard shortcut.
❌ "We'll fix accessibility later"
Retrofitting is 10× harder. Build a11y-aware from day one.
❌ Forgetting the announce action for dynamic content
Toast appears; screen reader user has no idea.
✅ SemanticsService.announce('Item deleted', TextDirection.ltr);
Interview follow-ups
-
Why are Material widgets "accessible by default" and what does that mean exactly? They ship with appropriate
Semanticsbaked in —ElevatedButtonreports as a button,Switchas a toggle, etc. They also respect text scaling, focus, and contrast through the theme. You break that only if you wrap them in opaque custom widgets that don't forward semantics. Most a11y bugs come from custom widgets, not Material ones. -
How do you handle accessibility for a custom-painted widget (CustomPaint)? Wrap or compose with
Semantics(...)to describe what's drawn, since screen readers can't read paint commands. For a custom chart, semantically describe the data ("Bar chart with 5 categories, January value 42, February value 38..."). For interactive painted regions, useSemanticswithonTap+ nested labels per region. -
What's the difference between
excludeSemantics,MergeSemantics, andBlockSemantics?excludeSemantics: true— drop this subtree from the accessibility tree (purely decorative).MergeSemantics— combine children's semantics into one node (e.g., icon + text read as one).BlockSemantics— sibling content underneath is hidden from screen readers (used for modal overlays). -
How do you avoid the "clamp textScaleFactor" anti-pattern? Design with elasticity: don't hard-code heights for text containers, use
Flexible/Expanded, let text wrap or scroll. Test at 1.5x and 2x font size — they're real user settings. If a screen breaks, fix the layout (more vertical space, scrollable content), don't disable scaling.
How helpful was this content?
Please sign in to rate this article.