Flutter Accessibility Skill
Purpose
Make Flutter apps usable by everyone — including users relying on screen readers, keyboard navigation, switch access, large text, or reduced motion. Accessibility is a release requirement, not an afterthought.
Scope and authority
Lead authority for:
- semantics tree annotation (Semantics, MergeSemantics, ExcludeSemantics)
- screen reader compatibility (TalkBack, VoiceOver, Narrator)
- focus traversal and keyboard navigation
- contrast ratio validation (WCAG 2.1 AA minimum)
- touch target sizing
- text scaling and dynamic type
- reduced motion support
- web accessibility (ARIA via Flutter)
This skill applies to ALL platforms (mobile, web, desktop).
Non-negotiable accessibility requirements
These apply to all apps. Exceptions require explicit documented justification.
| Requirement | Standard | Value |
|---|---|---|
| Minimum touch target | WCAG 2.5.5 | 48×48 logical pixels |
| Text contrast (body) | WCAG 1.4.3 AA | ≥ 4.5:1 |
| Text contrast (large) | WCAG 1.4.3 AA | ≥ 3:1 (≥18pt or ≥14pt bold) |
| No color-only information | WCAG 1.4.1 | Icon/text accompaniment required |
| Semantic labels | WCAG 1.1.1 | All interactive widgets labeled |
| Focus visible | WCAG 2.4.7 | Focus ring visible on all focusable elements |
| Text scaling | WCAG 1.4.4 | Layout functional at 200% text size |
Semantics tree implementation
Basic semantics
// ✅ Custom interactive widget — always add Semantics
class FavoriteButton extends StatelessWidget {
const FavoriteButton({super.key, required this.isFavorite, required this.onTap});
final bool isFavorite;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => Semantics(
label: isFavorite
? context.l10n.removeFromFavorites
: context.l10n.addToFavorites,
button: true,
checked: isFavorite,
child: GestureDetector(
onTap: onTap,
child: Icon(
isFavorite ? Icons.favorite : Icons.favorite_border,
semanticLabel: '', // ❌ Suppress default Icon label (parent Semantics handles it)
),
),
);
}
MergeSemantics for grouped widgets
// ✅ Merge related widgets into single screen reader announcement
MergeSemantics(
child: Row(
children: [
const Icon(Icons.star, color: Colors.amber),
Text('4.5'),
Text('(128 reviews)'),
],
),
)
// Screen reader announces: "4.5 128 reviews" as single unit
ExcludeSemantics for decorative content
// ✅ Hide decorative or redundant elements from screen readers
ExcludeSemantics(
child: Icon(Icons.circle, color: Colors.grey), // Visual decoration only
)
SemanticsRole for custom components
// ✅ Assign semantic role for proper screen reader interpretation
Semantics(
role: SemanticsRole.heading,
child: Text('Section Title', style: Theme.of(context).textTheme.headlineMedium),
)
Focus traversal
// ✅ Ensure logical tab order on forms
FocusTraversalGroup(
policy: OrderedTraversalPolicy(),
child: Column(
children: [
FocusTraversalOrder(
order: const NumericFocusOrder(1),
child: TextFormField(key: const Key('email')),
),
FocusTraversalOrder(
order: const NumericFocusOrder(2),
child: TextFormField(key: const Key('password')),
),
FocusTraversalOrder(
order: const NumericFocusOrder(3),
child: ElevatedButton(onPressed: _submit, child: const Text('Login')),
),
],
),
)
// ✅ Trap focus in modal dialogs
FocusScope(
child: Dialog(
child: _DialogContent(),
),
)
Touch target sizing
// ✅ Ensure 48×48 minimum touch target
// Even if visual size is smaller, expand tap area
InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(12), // Expands touch area to ≥48px
child: const Icon(Icons.close, size: 24),
),
)
// OR use SizedBox to enforce minimum size
SizedBox(
width: 48,
height: 48,
child: IconButton(
iconSize: 24,
onPressed: onTap,
icon: const Icon(Icons.close),
),
)
Text scaling support
// ✅ Test at 200% text scale — fix overflow
// DO NOT cap textScaleFactor — respect user's OS setting
Text(
userContent,
maxLines: 3, // ✅ Prevent unbounded overflow
overflow: TextOverflow.ellipsis,
)
// ❌ Never hardcode text scale
MediaQuery(
data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0), // PROHIBITED
child: child,
)
Color contrast validation
// Check contrast in design tokens
// Use https://webaim.org/resources/contrastchecker/
// ✅ High contrast pair (M3 default ColorScheme handles this)
color: Theme.of(context).colorScheme.onPrimary, // Text on primary
backgroundColor: Theme.of(context).colorScheme.primary,
// ❌ Custom colors without contrast validation
color: Colors.grey[400], // Against white background = 2.5:1 — FAILS
Reduced motion
// ✅ Respect system reduced motion preference
@override
Widget build(BuildContext context) {
final reduceMotion = MediaQuery.of(context).disableAnimations;
return AnimatedOpacity(
duration: reduceMotion ? Duration.zero : const Duration(milliseconds: 300),
opacity: isVisible ? 1.0 : 0.0,
child: child,
);
}
Web accessibility
// ✅ Enable semantics for web (disabled by default for perf)
void main() {
if (kIsWeb) {
SemanticsBinding.instance.ensureSemantics();
}
runApp(const MyApp());
}
// ✅ Use standard widgets for automatic ARIA role mapping
// TabBar → role="tablist"
// MenuAnchor → role="menu"
// Table → role="table"
// For custom: use SemanticsRole enum
Accessibility testing checklist
□ Navigate entire app with TalkBack (Android) or VoiceOver (iOS)
□ All interactive elements announced correctly
□ No focus traps outside of modals/dialogs
□ Tab key navigates all interactive elements on desktop/web
□ Test at 200% text scale — no overflow or clipping
□ Test at 0% animation (system reduced motion enabled)
□ Color contrast: validate all text against background
□ Custom widgets have Semantics labels
□ Error messages are announced (not just color changes)
□ Loading states announced ('Loading' or progress indicator)
□ Empty states announced ('No items found')
Widget test for semantics
testWidgets('FavoriteButton has correct semantics', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: FavoriteButton(isFavorite: false, onTap: () {}),
),
);
final semantics = tester.getSemantics(find.byType(FavoriteButton));
expect(semantics.label, 'Add to favorites');
expect(semantics.hasFlag(SemanticsFlag.isButton), true);
semantics.dispose();
});
Anti-pattern detection
- Custom interactive widgets without Semantics labels → WCAG violation
- Tap targets smaller than 48×48 → WCAG 2.5.5 violation
- Color-only error indication → WCAG 1.4.1 violation
- Hardcoded textScaleFactor → WCAG 1.4.4 violation
- No focus visible on interactive elements → WCAG 2.4.7 violation
- Missing screen reader testing before release
- Decorative icons without ExcludeSemantics → noisy screen reader
Cross-skill handoff payload
Use the standard payload from ../../AGENTS.md.
Set requesting_skill to flutter-accessibility.
Output contract
Follow global section order from ../../AGENTS.md. Also include:
Semantics implementation planFocus traversal designContrast validation resultsTouch target complianceAccessibility test plan
Related resources
references/wcag-guide.mdreferences/semantics-guide.mdreferences/talkback-testing.mdtemplates/accessible-widget.darttemplates/accessibility-test.dart