Accessibility
Flutter accessibility — Semantics, screen readers, focus management, dynamic type, contrast, testing
Accessibility
Accessibility is not optional polish — it is a core engineering requirement. Flutter provides a single Semantics tree that maps to platform accessibility APIs on both iOS and Android, giving you one implementation path instead of two. This page covers everything needed to ship an app that works for all users.
Why Accessibility Matters
Legal Requirements
| Regulation | Scope | Key Requirement |
|---|---|---|
| ADA (Americans with Disabilities Act) | US — public accommodations, including apps | Equivalent access for users with disabilities |
| WCAG 2.1 AA | International standard, referenced by most laws | Perceivable, Operable, Understandable, Robust |
| European Accessibility Act (2025) | EU — products and services sold after June 2025 | Digital services must meet EN 301 549 (maps to WCAG 2.1 AA) |
| Section 508 | US federal agencies | WCAG 2.0 AA conformance for government software |
Non-compliance exposes organizations to lawsuits, fines, and app store rejections. Apple and Google both review accessibility during app review and can reject apps that fail basic checks.
Business Case
The World Health Organization estimates that 15% of the global population — over one billion people — lives with some form of disability. Beyond permanent disabilities, situational impairments (bright sunlight, one hand occupied, noisy environment) affect everyone. Accessible apps convert more users and retain them longer.
Flutter's Structural Advantage
Flutter renders its own pixels through Skia/Impeller, but it also maintains a parallel Semantics tree that is exposed to platform accessibility services. This means one set of Semantics annotations covers both VoiceOver (iOS) and TalkBack (Android) — unlike native development where you annotate UIKit and Android Views separately.
The Semantics Tree
How It Works
Widget tree
↓
Element tree (diff engine)
↓
RenderObject tree (layout + paint)
↓
Semantics tree (accessibility metadata)
↓
Platform a11y API
├── UIAccessibility (iOS)
└── AccessibilityNodeInfo (Android)Every frame, Flutter walks the RenderObject tree and assembles a parallel tree of SemanticsNode objects. The engine serializes these nodes into platform-specific accessibility structures. Screen readers, switch devices, and braille displays all consume these structures.
Implicit Semantics
Many built-in widgets automatically contribute semantics without any annotation:
| Widget | Implicit Semantics |
|---|---|
Text | Exposes its string as a label |
ElevatedButton / TextButton | Marked as a button with the child text as its label |
TextField | Exposes value, hint, editing state |
Checkbox / Switch | Exposes checked/unchecked state |
Image | Exposes semanticLabel if provided |
Slider | Exposes value, increase/decrease actions |
AppBar | Title exposed as a header |
This implicit behavior means that apps built with standard Material/Cupertino widgets start with a reasonable accessibility baseline.
Explicit Semantics
For custom widgets that draw their own content, you must add semantics manually:
// Custom status indicator that draws a colored circle
class StatusIndicator extends StatelessWidget {
final bool isOnline;
const StatusIndicator({super.key, required this.isOnline});
@override
Widget build(BuildContext context) {
return Semantics(
label: isOnline ? 'Online' : 'Offline',
// Prevent children from contributing their own semantics
excludeSemantics: true,
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isOnline ? Colors.green : Colors.grey,
),
),
);
}
}SemanticsProperties Reference
The Semantics widget accepts a wide set of properties:
| Property | Type | Purpose |
|---|---|---|
label | String | Primary description read by screen reader |
hint | String | Describes what happens on activation ("Double tap to open") |
value | String | Current value (slider position, text field content) |
readOnly | bool | Marks the element as non-editable |
enabled | bool | Whether the control accepts interaction |
button | bool | Marks as a button role |
header | bool | Marks as a section header |
link | bool | Marks as a hyperlink |
image | bool | Marks as an image |
liveRegion | bool | Content changes are announced automatically |
onTap | VoidCallback | Custom tap handler exposed to assistive tech |
onLongPress | VoidCallback | Custom long-press handler |
increasedValue / decreasedValue | String | Values after increase/decrease actions |
MergeSemantics and ExcludeSemantics
MergeSemantics collapses multiple semantic nodes into one, which simplifies the reading experience for screen reader users:
// Without merge: screen reader reads "Price", then "$42.99" as separate items
// With merge: reads "Price $42.99" as one unit
MergeSemantics(
child: Row(
children: [
Text('Price'),
const SizedBox(width: 8),
Text('\$42.99'),
],
),
)ExcludeSemantics removes a subtree from the accessibility tree entirely. Use it only for purely decorative elements:
// Decorative background pattern — not useful to screen reader users
ExcludeSemantics(
child: CustomPaint(painter: BackgroundPatternPainter()),
)Never use ExcludeSemantics on interactive elements. Wrapping a button or input in ExcludeSemantics makes it invisible to assistive technology. If a sighted user can tap it, a screen reader user must be able to reach it.
Debugging the Semantics Tree
Dump the full semantics tree from the command line:
flutter run --dump-semantics-tree-in-traversal-orderOr enable the Semantics Debugger overlay in code:
MaterialApp(
showSemanticsDebugger: true,
// ...
)This replaces the visual rendering with a text overlay showing what screen readers see — an immediate way to spot missing labels or wrong groupings.
Accessible Custom Widget Example
A complete example of a custom rating widget with proper semantics:
class StarRating extends StatelessWidget {
final int rating;
final int maxRating;
final ValueChanged<int>? onChanged;
const StarRating({
super.key,
required this.rating,
this.maxRating = 5,
this.onChanged,
});
@override
Widget build(BuildContext context) {
return Semantics(
label: 'Rating',
value: '$rating out of $maxRating stars',
hint: onChanged != null ? 'Double tap to change rating' : null,
enabled: onChanged != null,
increasedValue: rating < maxRating
? '${rating + 1} out of $maxRating stars'
: null,
decreasedValue: rating > 0
? '${rating - 1} out of $maxRating stars'
: null,
onIncrease: onChanged != null && rating < maxRating
? () => onChanged!(rating + 1)
: null,
onDecrease: onChanged != null && rating > 0
? () => onChanged!(rating - 1)
: null,
child: ExcludeSemantics(
child: Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(maxRating, (i) {
return GestureDetector(
onTap: onChanged != null ? () => onChanged!(i + 1) : null,
child: Icon(
i < rating ? Icons.star : Icons.star_border,
color: Colors.amber,
size: 32,
),
);
}),
),
),
);
}
}The key pattern: wrap the entire custom widget in a single Semantics node with meaningful value, increasedValue, and decreasedValue, then wrap the visual children in ExcludeSemantics so screen readers see one coherent control rather than five individual star icons.
Screen Readers
VoiceOver and TalkBack Behavior Differences
| Behavior | VoiceOver (iOS) | TalkBack (Android) |
|---|---|---|
| Activation gesture | Double tap | Double tap |
| Scrolling | Three-finger swipe | Two-finger swipe |
| Element navigation | Swipe left/right | Swipe left/right |
| Custom actions | Rotor (two-finger rotate) | Local context menu |
| Heading navigation | Rotor → Headings | Reading controls → Headings |
| Announcement verbosity | Generally terser | More verbose by default |
Always test on both platforms. A label that sounds natural on VoiceOver may be redundant on TalkBack or vice versa.
Reading Order
Screen readers traverse the Semantics tree in the order nodes appear. By default, this follows the widget tree order, which maps to visual reading order (top-to-bottom, start-to-end). Problems arise when visual layout diverges from tree order — for example, a Stack where the visually top element is last in the children list.
Overriding Traversal Order with OrdinalSortKey
// Force the "Skip" button to be read after the "Submit" button
// even though "Skip" appears first in the widget tree
Column(
children: [
Semantics(
sortKey: const OrdinalSortKey(1),
child: TextButton(onPressed: skip, child: const Text('Skip')),
),
Semantics(
sortKey: const OrdinalSortKey(0),
child: ElevatedButton(onPressed: submit, child: const Text('Submit')),
),
],
)Use OrdinalSortKey sparingly. If you need many sort overrides, restructure the widget tree to match the intended reading order instead.
Common Screen Reader Issues
- Decorative images read aloud: An
Imagewithout asemanticLabelmay still expose the asset path. Always setsemanticLabelor wrap inExcludeSemantics. - Missing button labels: An
IconButtonwith notooltipproduces "button" with no description. Always settooltipon icon-only buttons. - Vague announcements: "Button, double tap to activate" with no context. Provide a descriptive label.
- Redundant information: A card that reads "Order 12345, Order number 12345, Status shipped, Order status shipped" — merge semantics or restructure.
Live Announcements with SemanticsService
Use SemanticsService.announce() to push transient announcements — loading states, errors, success confirmations — to screen readers without changing focus:
import 'package:flutter/semantics.dart';
Future<void> submitOrder(BuildContext context) async {
SemanticsService.announce('Submitting order', TextDirection.ltr);
try {
await api.submitOrder();
SemanticsService.announce('Order submitted successfully', TextDirection.ltr);
} catch (e) {
SemanticsService.announce('Order submission failed. Please try again.', TextDirection.ltr);
}
}Announce sparingly. Over-announcing interrupts the user's current reading position. Reserve announcements for state changes the user is waiting for: async operation results, error conditions, and live data updates.
Custom Slider with Proper Semantics
class AccessibleCustomSlider extends StatefulWidget {
final double value;
final double min;
final double max;
final String label;
final ValueChanged<double> onChanged;
const AccessibleCustomSlider({
super.key,
required this.value,
this.min = 0,
this.max = 100,
required this.label,
required this.onChanged,
});
@override
State<AccessibleCustomSlider> createState() => _AccessibleCustomSliderState();
}
class _AccessibleCustomSliderState extends State<AccessibleCustomSlider> {
double _currentValue = 0;
@override
void initState() {
super.initState();
_currentValue = widget.value;
}
@override
void didUpdateWidget(AccessibleCustomSlider oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.value != widget.value) _currentValue = widget.value;
}
void _increment() {
final step = (widget.max - widget.min) / 10;
final newVal = (_currentValue + step).clamp(widget.min, widget.max);
setState(() => _currentValue = newVal);
widget.onChanged(newVal);
}
void _decrement() {
final step = (widget.max - widget.min) / 10;
final newVal = (_currentValue - step).clamp(widget.min, widget.max);
setState(() => _currentValue = newVal);
widget.onChanged(newVal);
}
@override
Widget build(BuildContext context) {
final percentage = ((_currentValue - widget.min) /
(widget.max - widget.min) * 100)
.round();
return Semantics(
label: widget.label,
value: '$percentage percent',
increasedValue: '${(percentage + 10).clamp(0, 100)} percent',
decreasedValue: '${(percentage - 10).clamp(0, 100)} percent',
onIncrease: _increment,
onDecrease: _decrement,
slider: true,
child: GestureDetector(
onHorizontalDragUpdate: (details) {
final box = context.findRenderObject() as RenderBox;
final fraction = (details.localPosition.dx / box.size.width)
.clamp(0.0, 1.0);
final newVal =
widget.min + fraction * (widget.max - widget.min);
setState(() => _currentValue = newVal);
widget.onChanged(newVal);
},
child: CustomPaint(
size: const Size(double.infinity, 48),
painter: _SliderPainter(
fraction: (_currentValue - widget.min) /
(widget.max - widget.min),
activeColor: Theme.of(context).colorScheme.primary,
trackColor: Theme.of(context).colorScheme.surfaceContainerHighest,
),
),
),
);
}
}
class _SliderPainter extends CustomPainter {
final double fraction;
final Color activeColor;
final Color trackColor;
_SliderPainter({
required this.fraction,
required this.activeColor,
required this.trackColor,
});
@override
void paint(Canvas canvas, Size size) {
final trackY = size.height / 2;
final trackPaint = Paint()..color = trackColor;
final activePaint = Paint()..color = activeColor;
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(0, trackY - 2, size.width, 4),
const Radius.circular(2),
),
trackPaint,
);
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(0, trackY - 2, size.width * fraction, 4),
const Radius.circular(2),
),
activePaint,
);
canvas.drawCircle(
Offset(size.width * fraction, trackY),
12,
activePaint,
);
}
@override
bool shouldRepaint(_SliderPainter old) =>
fraction != old.fraction ||
activeColor != old.activeColor ||
trackColor != old.trackColor;
}Focus Management
FocusNode and FocusScope
Every focusable widget in Flutter is backed by a FocusNode. FocusScope groups nodes and controls which scope currently holds primary focus:
class SearchPage extends StatefulWidget {
const SearchPage({super.key});
@override
State<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
final _searchFocus = FocusNode();
@override
void dispose() {
_searchFocus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Search')),
body: Column(
children: [
TextField(
focusNode: _searchFocus,
autofocus: true, // grab focus when this screen appears
decoration: const InputDecoration(hintText: 'Search...'),
),
// ...results
],
),
);
}
}FocusTraversalGroup and Policies
FocusTraversalGroup defines a scope for keyboard (and switch control) tab traversal:
FocusTraversalGroup(
policy: OrderedTraversalPolicy(),
child: Column(
children: [
FocusTraversalOrder(
order: const NumericFocusOrder(2),
child: TextField(decoration: const InputDecoration(labelText: 'Email')),
),
FocusTraversalOrder(
order: const NumericFocusOrder(1),
child: TextField(decoration: const InputDecoration(labelText: 'Name')),
),
FocusTraversalOrder(
order: const NumericFocusOrder(3),
child: ElevatedButton(
onPressed: () {},
child: const Text('Submit'),
),
),
],
),
)| Policy | Behavior |
|---|---|
ReadingOrderTraversalPolicy | Default — follows visual reading order (LTR/RTL aware) |
OrderedTraversalPolicy | Respects explicit FocusTraversalOrder annotations |
WidgetOrderTraversalPolicy | Follows widget tree order regardless of position |
Focus After Navigation
When pushing a new route, set autofocus: true on the primary content element, or manually request focus after the route transition completes:
Navigator.push(context, MaterialPageRoute(
builder: (_) => DetailPage(),
)).then((_) {
// Return focus to a specific element after popping back
_listFocusNode.requestFocus();
});Accessible Form with Focus Management
class AccessibleForm extends StatefulWidget {
const AccessibleForm({super.key});
@override
State<AccessibleForm> createState() => _AccessibleFormState();
}
class _AccessibleFormState extends State<AccessibleForm> {
final _formKey = GlobalKey<FormState>();
final _nameFocus = FocusNode();
final _emailFocus = FocusNode();
final _passwordFocus = FocusNode();
final _nameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
@override
void dispose() {
_nameFocus.dispose();
_emailFocus.dispose();
_passwordFocus.dispose();
_nameController.dispose();
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
void _submit() {
if (!_formKey.currentState!.validate()) {
// Find the first field with an error and move focus to it
if (_nameController.text.isEmpty) {
_nameFocus.requestFocus();
SemanticsService.announce(
'Name is required',
TextDirection.ltr,
);
} else if (!_emailController.text.contains('@')) {
_emailFocus.requestFocus();
SemanticsService.announce(
'Please enter a valid email address',
TextDirection.ltr,
);
} else {
_passwordFocus.requestFocus();
SemanticsService.announce(
'Password must be at least 8 characters',
TextDirection.ltr,
);
}
return;
}
// Submit the form
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: FocusTraversalGroup(
policy: OrderedTraversalPolicy(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FocusTraversalOrder(
order: const NumericFocusOrder(0),
child: TextFormField(
controller: _nameController,
focusNode: _nameFocus,
autofocus: true,
decoration: const InputDecoration(labelText: 'Full name'),
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) => _emailFocus.requestFocus(),
validator: (v) =>
v == null || v.isEmpty ? 'Name is required' : null,
),
),
const SizedBox(height: 16),
FocusTraversalOrder(
order: const NumericFocusOrder(1),
child: TextFormField(
controller: _emailController,
focusNode: _emailFocus,
decoration: const InputDecoration(labelText: 'Email address'),
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) => _passwordFocus.requestFocus(),
validator: (v) =>
v != null && v.contains('@') ? null : 'Enter a valid email',
),
),
const SizedBox(height: 16),
FocusTraversalOrder(
order: const NumericFocusOrder(2),
child: TextFormField(
controller: _passwordController,
focusNode: _passwordFocus,
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _submit(),
validator: (v) => v != null && v.length >= 8
? null
: 'At least 8 characters required',
),
),
const SizedBox(height: 24),
FocusTraversalOrder(
order: const NumericFocusOrder(3),
child: ElevatedButton(
onPressed: _submit,
child: const Text('Create account'),
),
),
],
),
),
);
}
}Key patterns: move focus to the first validation error, announce errors for screen reader users, chain field submission with onFieldSubmitted so keyboard users can tab naturally through the form.
Dynamic Type and Text Scaling
Reading the Text Scale Factor
Flutter 3.16+ replaced textScaleFactor (a single double) with TextScaler, which supports non-linear scaling:
// Flutter 3.16+
final scaler = MediaQuery.textScalerOf(context);
final scaledSize = scaler.scale(16.0); // base 16 → scaled value
// Pre-3.16 (deprecated)
// final factor = MediaQuery.textScaleFactorOf(context);Testing at Multiple Scale Factors
Wrap your app or a subtree in a MediaQuery override to simulate different settings:
// In a widget test or debug build
MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: const TextScaler.linear(2.0),
),
child: const MyWidget(),
)Test at 1.0x (default), 1.5x, and 2.0x as a minimum. Some users set even higher values.
Layout Survival at 2x Scale
The primary failure mode is text overflowing fixed-height containers. Avoid hardcoded heights for anything containing text:
// BROKEN at 2x text scale: text overflows the 48px box
SizedBox(
height: 48,
child: Text('This text will overflow at large scale factors'),
)
// CORRECT: let the container grow with its content
ConstrainedBox(
constraints: const BoxConstraints(minHeight: 48),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
child: Text('This text wraps and the container expands'),
),
)A more complete example of a card that handles text scaling gracefully:
class ScalableInfoCard extends StatelessWidget {
final String title;
final String subtitle;
final IconData icon;
const ScalableInfoCard({
super.key,
required this.title,
required this.subtitle,
required this.icon,
});
@override
Widget build(BuildContext context) {
// Switch to vertical layout when text scaling is aggressive
final scaler = MediaQuery.textScalerOf(context);
final isLargeText = scaler.scale(1.0) > 1.3;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: isLargeText
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 32),
const SizedBox(height: 8),
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(subtitle, style: Theme.of(context).textTheme.bodyMedium),
],
)
: Row(
children: [
Icon(icon, size: 32),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: Theme.of(context).textTheme.titleMedium),
Text(subtitle,
style: Theme.of(context).textTheme.bodyMedium),
],
),
),
],
),
),
);
}
}Never clamp textScaler to 1.0. Some apps force MediaQuery(data: data.copyWith(textScaler: TextScaler.noScaling), ...) at the root to "prevent layout issues." This disables a core accessibility feature. Fix your layouts instead.
Color and Contrast
WCAG Contrast Ratios
| Element | Minimum Ratio | Example |
|---|---|---|
| Normal text (< 18pt / < 14pt bold) | 4.5:1 | Body copy, labels |
| Large text (>= 18pt / >= 14pt bold) | 3:1 | Headings, large buttons |
| UI components and graphical objects | 3:1 | Icons, input borders, focus rings |
Dark Mode Is Not Just Inversion
Simply swapping black and white does not guarantee contrast compliance. Common failures:
- Grey text on dark backgrounds dropping below 4.5:1
- Colored buttons (brand colors) that pass on light but fail on dark
- Disabled states that become invisible
Always verify contrast ratios in both themes.
Semantic Color Usage
Never use color as the sole carrier of information:
// BAD: relies on color alone to indicate error
TextField(
decoration: InputDecoration(
// A color-blind user cannot distinguish this from a normal field
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: hasError ? Colors.red : Colors.grey),
),
),
)
// GOOD: color + icon + text
TextField(
decoration: InputDecoration(
errorText: hasError ? 'This field is required' : null,
suffixIcon: hasError
? const Icon(Icons.error_outline, semanticLabel: 'Error')
: null,
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: hasError ? Colors.red : Colors.grey),
),
),
)Contrast-Safe Theme with ThemeExtension
class ContrastSafeColors extends ThemeExtension<ContrastSafeColors> {
/// Error: red with sufficient contrast on both light and dark backgrounds
final Color error;
/// Success: not pure green (fails for deuteranopia) — use teal-shifted green
final Color success;
/// Warning: dark amber, not yellow (yellow fails contrast on white)
final Color warning;
/// Info: blue, universally distinguishable
final Color info;
const ContrastSafeColors({
required this.error,
required this.success,
required this.warning,
required this.info,
});
// Light theme: verified against white (#FFFFFF)
static const light = ContrastSafeColors(
error: Color(0xFFB3261E), // 5.9:1 on white
success: Color(0xFF1B6D3D), // 5.4:1 on white
warning: Color(0xFF7C4D12), // 5.1:1 on white
info: Color(0xFF1A56DB), // 5.7:1 on white
);
// Dark theme: verified against dark surface (#1C1B1F)
static const dark = ContrastSafeColors(
error: Color(0xFFF2B8B5), // 7.3:1 on dark surface
success: Color(0xFF7DD9A0), // 6.8:1 on dark surface
warning: Color(0xFFE8C871), // 7.1:1 on dark surface
info: Color(0xFF93B4F5), // 6.2:1 on dark surface
);
@override
ContrastSafeColors copyWith({
Color? error,
Color? success,
Color? warning,
Color? info,
}) =>
ContrastSafeColors(
error: error ?? this.error,
success: success ?? this.success,
warning: warning ?? this.warning,
info: info ?? this.info,
);
@override
ContrastSafeColors lerp(ThemeExtension<ContrastSafeColors>? other, double t) {
if (other is! ContrastSafeColors) return this;
return ContrastSafeColors(
error: Color.lerp(error, other.error, t)!,
success: Color.lerp(success, other.success, t)!,
warning: Color.lerp(warning, other.warning, t)!,
info: Color.lerp(info, other.info, t)!,
);
}
}Error State with Multiple Cues
class AccessibleErrorBanner extends StatelessWidget {
final String message;
const AccessibleErrorBanner({super.key, required this.message});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).extension<ContrastSafeColors>()!;
return Semantics(
liveRegion: true, // announce when this appears
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.error.withOpacity(0.1),
border: Border.all(color: colors.error),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.error_outline, color: colors.error, semanticLabel: ''),
const SizedBox(width: 12),
Expanded(
child: Text(
message,
style: TextStyle(color: colors.error),
),
),
],
),
),
);
}
}Three channels convey the error: color (red border and background), iconography (error icon), and text (the error message). A user who cannot perceive any one of these channels can still understand the state from the other two.
Motion and Vestibular Sensitivity
Honoring Reduce-Motion Preferences
Both iOS and Android expose a "reduce motion" accessibility setting. Flutter surfaces this through MediaQuery:
@override
Widget build(BuildContext context) {
final reduceMotion = MediaQuery.disableAnimationsOf(context);
return AnimatedContainer(
duration: reduceMotion ? Duration.zero : const Duration(milliseconds: 300),
curve: Curves.easeInOut,
height: isExpanded ? 200 : 0,
child: content,
);
}Conditional Animation Pattern
For complex page transitions, parallax effects, or auto-scrolling carousels:
class MotionAwarePageRoute<T> extends MaterialPageRoute<T> {
MotionAwarePageRoute({required super.builder});
@override
Duration get transitionDuration {
// Check the navigator context for reduce-motion
// Fall back to instant transition when motion is reduced
return const Duration(milliseconds: 300);
}
@override
Widget buildTransitions(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
if (MediaQuery.disableAnimationsOf(context)) {
// No transition — instant cut
return child;
}
return SlideTransition(
position: animation.drive(
Tween(begin: const Offset(1, 0), end: Offset.zero)
.chain(CurveTween(curve: Curves.easeOut)),
),
child: child,
);
}
}Auto-playing carousels and parallax scrolling are the most common vestibular triggers. If your app includes these, gate them behind the reduce-motion check and provide a static fallback. A carousel can display a single hero image; parallax can flatten to normal scrolling.
Touch Target Size
Minimum Sizes
| Platform | Minimum Target Size | Source |
|---|---|---|
| Android (Material) | 48 x 48 dp | Material Design guidelines |
| iOS (Human Interface) | 44 x 44 pt | Apple HIG |
| WCAG 2.5.5 (AAA) | 44 x 44 CSS px | WCAG 2.1 |
MaterialTapTargetSize
Flutter's Material widgets have a built-in minimum tap target via MaterialTapTargetSize:
// Default: adds padding to reach 48x48
IconButton(
icon: const Icon(Icons.close),
onPressed: () {},
// materialTapTargetSize: MaterialTapTargetSize.padded (default)
)
// Opt out (use only when targets are in a dense, keyboard-accessible toolbar)
IconButton(
icon: const Icon(Icons.close),
onPressed: () {},
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
)Ensuring Minimum Targets on Custom Widgets
When building custom interactive elements that do not extend Material widgets:
class AccessibleTapTarget extends StatelessWidget {
final Widget child;
final VoidCallback onTap;
final String semanticLabel;
const AccessibleTapTarget({
super.key,
required this.child,
required this.onTap,
required this.semanticLabel,
});
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: semanticLabel,
child: GestureDetector(
onTap: onTap,
child: ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 48,
minHeight: 48,
),
child: Center(child: child),
),
),
);
}
}Small targets are the most common accessibility failure in mobile apps. The WCAG 2.5.8 criterion (new in WCAG 2.2) requires a 24x24 CSS px minimum at AA level. Material's 48dp default exceeds this, but custom widgets and shrinkWrap overrides frequently violate it. Run the Android Accessibility Scanner to catch undersized targets.
Testing Accessibility
Platform Accessibility Tools
| Tool | Platform | What It Tests |
|---|---|---|
| Accessibility Inspector | iOS Simulator | Semantics tree, VoiceOver simulation |
| Accessibility Scanner | Android | Touch target size, contrast, labels |
| VoiceOver (manual) | iOS device | Real screen reader experience |
| TalkBack (manual) | Android device | Real screen reader experience |
Flutter Semantics Debugger
Enable the overlay to see what the accessibility tree looks like:
MaterialApp(
showSemanticsDebugger: true,
home: MyHomePage(),
)Widget Test Semantics Matchers
Flutter's test framework provides matchesSemantics for verifying accessibility properties in automated tests:
testWidgets('star rating has correct semantics', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: StarRating(rating: 3, maxRating: 5),
),
),
);
expect(
tester.getSemantics(find.byType(StarRating)),
matchesSemantics(
label: 'Rating',
value: '3 out of 5 stars',
increasedValue: '4 out of 5 stars',
decreasedValue: '2 out of 5 stars',
hasIncreaseAction: true,
hasDecreaseAction: true,
),
);
});
testWidgets('error banner is a live region', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(
extensions: const [ContrastSafeColors.light],
),
home: const Scaffold(
body: AccessibleErrorBanner(message: 'Network error'),
),
),
);
final semantics = tester.getSemantics(
find.byType(AccessibleErrorBanner),
);
expect(semantics, matchesSemantics(
isLiveRegion: true,
hasImplicitScrolling: false,
));
});Automated CI Checks
Create a custom test helper that enforces accessibility rules across all screens:
/// Verify that no interactive semantic node has an empty label
void assertNoEmptyLabels(WidgetTester tester) {
final semanticsOwner = tester.binding.pipelineOwner.semanticsOwner;
if (semanticsOwner == null) return;
void visit(SemanticsNode node) {
final data = node.getSemanticsData();
final isInteractive = data.hasAction(SemanticsAction.tap) ||
data.hasAction(SemanticsAction.longPress) ||
data.hasFlag(SemanticsFlag.isButton);
if (isInteractive) {
expect(
data.label.isNotEmpty || data.value.isNotEmpty,
isTrue,
reason: 'Interactive node at ${node.rect} has no label or value. '
'Add a semanticLabel, tooltip, or Semantics widget.',
);
}
node.visitChildren(visit);
}
visit(semanticsOwner.rootSemanticsNode!);
}
// Usage in tests
testWidgets('home page has no empty labels', (tester) async {
await tester.pumpWidget(const MyApp());
await tester.pumpAndSettle();
assertNoEmptyLabels(tester);
});Manual Testing Protocol
No automated tool catches everything. Run this protocol before every release:
- VoiceOver walkthrough (iOS): Navigate every screen with swipe gestures. Every interactive element must announce its purpose.
- TalkBack walkthrough (Android): Same as above. Check that reading order matches visual layout.
- Keyboard navigation (connected keyboard or desktop): Tab through all interactive elements. Verify visible focus ring.
- Large text (Settings > Accessibility > Larger Text): Set to maximum. Verify no text truncation without ellipsis, no overlapping elements.
- Bold text (iOS): Enable bold text. Verify layout integrity.
- Reduce motion: Enable reduce motion. Verify animations are suppressed or simplified.
- Color inversion / high contrast: Verify readability.
- Switch control: Navigate with switch control on iOS or Switch Access on Android. Verify all interactive elements are reachable.
Anti-Patterns
Empty Semantics Labels
// WRONG: worse than no label — screen reader reads "button" with no context
Semantics(
label: '',
button: true,
child: myIcon,
)
// RIGHT: descriptive label
Semantics(
label: 'Close dialog',
button: true,
child: myIcon,
)ExcludeSemantics on Interactive Elements
// WRONG: makes the button invisible to assistive technology
ExcludeSemantics(
child: IconButton(
icon: const Icon(Icons.delete),
onPressed: deleteItem,
),
)
// RIGHT: if the button's default semantics are insufficient, override them
Semantics(
label: 'Delete order',
button: true,
child: IconButton(
icon: const Icon(Icons.delete),
onPressed: deleteItem,
tooltip: 'Delete order',
),
)Images Without semanticLabel
// WRONG: screen reader may announce the file path
Image.asset('assets/profile.png')
// RIGHT: meaningful description for content images
Image.asset(
'assets/profile.png',
semanticLabel: 'User profile photo',
)
// RIGHT: purely decorative image excluded from semantics
ExcludeSemantics(
child: Image.asset('assets/decorative_wave.png'),
)Custom Gesture Detectors Without Semantics
// WRONG: invisible to screen readers
GestureDetector(
onTap: () => toggleFavorite(),
onLongPress: () => showOptions(),
child: const Icon(Icons.favorite_border),
)
// RIGHT: semantics expose both actions
Semantics(
label: 'Favorite',
hint: 'Double tap to toggle, long press for options',
button: true,
onTap: () => toggleFavorite(),
onLongPress: () => showOptions(),
child: GestureDetector(
onTap: () => toggleFavorite(),
onLongPress: () => showOptions(),
child: const Icon(Icons.favorite_border),
),
)Infinite Scroll Without Announcements
// WRONG: new content appears silently — screen reader user does not know
NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification.metrics.pixels >=
notification.metrics.maxScrollExtent - 200) {
loadMoreItems();
}
return false;
},
child: ListView.builder(...),
)
// RIGHT: announce when new content loads
void loadMoreItems() async {
final newItems = await api.fetchMore();
setState(() => items.addAll(newItems));
SemanticsService.announce(
'${newItems.length} more items loaded',
TextDirection.ltr,
);
}Dialogs That Do Not Trap Focus
Flutter's showDialog automatically traps focus within the dialog via ModalBarrier. If you build a custom overlay instead of using showDialog, you must manage focus trapping yourself:
// WRONG: custom overlay with no focus trap — keyboard/screen reader can
// escape behind it
Overlay.of(context).insert(OverlayEntry(
builder: (_) => MyCustomDialog(),
));
// RIGHT: use showDialog or showModalBottomSheet, which handle focus
// trapping and barrier semantics automatically
showDialog(
context: context,
builder: (_) => AlertDialog(
title: const Text('Confirm deletion'),
content: const Text('This action cannot be undone.'),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
TextButton(onPressed: () { delete(); Navigator.pop(context); }, child: const Text('Delete')),
],
),
);Accessibility Checklist
Run through this table before every release:
| Category | Check | How to Verify |
|---|---|---|
| Semantics | All interactive elements have descriptive labels | showSemanticsDebugger: true, VoiceOver/TalkBack walkthrough |
| Semantics | No empty label: '' on interactive nodes | Custom lint or CI test with assertNoEmptyLabels |
| Semantics | Decorative images excluded, content images labeled | Code review, Semantics Debugger |
| Semantics | Custom widgets expose roles (button, slider, header) | matchesSemantics tests |
| Focus | Dialogs and new screens receive focus automatically | Keyboard navigation test |
| Focus | Focus moves to first error on form validation failure | Manual test with screen reader |
| Focus | Tab order matches visual reading order | Keyboard walkthrough |
| Text Scale | Layout survives 2x text scale without truncation or overflow | MediaQuery override in tests, device settings |
| Text Scale | No hardcoded heights on text containers | Code review |
| Contrast | Text meets 4.5:1 (normal) or 3:1 (large) against background | Accessibility Scanner, manual check with contrast tool |
| Contrast | UI components (icons, borders) meet 3:1 against adjacent colors | Manual check |
| Contrast | Both light and dark themes verified | Test both themes |
| Color | Information not conveyed by color alone | Check error/success/warning states |
| Motion | Animations respect disableAnimationsOf(context) | Enable reduce motion in device settings |
| Motion | No auto-playing animations without user control | Manual review |
| Touch | All interactive targets are at least 48x48 dp | Android Accessibility Scanner |
| Touch | Custom widgets use ConstrainedBox or equivalent for minimum size | Code review |
| Screen Reader | VoiceOver walkthrough of all screens completed | Manual test on iOS device |
| Screen Reader | TalkBack walkthrough of all screens completed | Manual test on Android device |
| Live Regions | Async state changes (loading, error, success) are announced | Screen reader test during network operations |
Accessibility is not a one-time effort. Every feature addition can introduce regressions. Include the checklist items above in your pull request template, add matchesSemantics tests for every new custom widget, and schedule periodic manual screen reader walkthroughs. The cost of maintaining accessibility is low when it is part of the development cycle from the start; retrofitting it later is expensive and error-prone.