Accessibility in Flutter apps
By John · 4 December 2025
Flutter has strong accessibility built in, but it requires intentional effort to make apps truly usable for people with visual, motor, or cognitive disabilities. This guide covers the practical implementation — from semantic labels to focus management.
Understanding the Semantics layer
Flutter renders pixels; the OS reads a parallel Semantics tree to communicate with accessibility services (TalkBack on Android, VoiceOver on iOS). Most Flutter widgets build their semantic meaning automatically, but many need help.
Test your app with screen readers on — Android TalkBack and iOS VoiceOver — to catch gaps that visual inspection won't reveal.
Semantic labels for images and icons
// BAD: VoiceOver says "image"
Image.network(product.imageUrl)
// GOOD: VoiceOver says "Blue Running Shoe, size 42"
Image.network(
product.imageUrl,
semanticLabel: '${product.name}, size ${product.size}',
)
// Icon without visible label: needs tooltip or semanticLabel
IconButton(
icon: const Icon(Icons.shopping_cart),
onPressed: onCartTap,
tooltip: 'Shopping cart', // This becomes the semantic label
)
// Decorative images: exclude from semantics
Image.network(
backgroundUrl,
semanticLabel: '', // Empty string = excluded from semantics
excludeFromSemantics: true,
)
Semantics widget
Wrap custom widgets to provide meaning:
// Price tag that combines multiple text widgets into one semantic element
Semantics(
label: 'Price: \$${product.price}', // What screen reader announces
child: Row(
children: [
const Text('\$', style: TextStyle(fontSize: 14)),
Text(product.price.toStringAsFixed(2), style: const TextStyle(fontSize: 24)),
],
),
)
// Interactive element with custom action description
Semantics(
button: true,
label: 'Like this post',
hint: 'Double tap to like',
onTap: onLike,
child: const LikeButton(),
)
Accessibility for custom interactive widgets
class StarRating extends StatelessWidget {
const StarRating({super.key, required this.rating, this.onChanged});
final double rating;
final ValueChanged<double>? onChanged;
@override
Widget build(BuildContext context) {
return Semantics(
label: 'Rating: ${rating.toStringAsFixed(1)} out of 5 stars',
// If interactive:
slider: onChanged != null,
value: rating.toString(),
increasedValue: (rating + 0.5).clamp(0, 5).toString(),
decreasedValue: (rating - 0.5).clamp(0, 5).toString(),
onIncrease: onChanged != null ? () => onChanged!(rating + 0.5) : null,
onDecrease: onChanged != null ? () => onChanged!(rating - 0.5) : null,
child: Row(
children: List.generate(5, (i) {
return Icon(
i < rating ? Icons.star : Icons.star_border,
color: Colors.amber,
);
}),
),
);
}
}
Focus management
When a modal opens, focus should move to it. When it closes, focus should return to the element that opened it.
// Move focus to a specific element
final _focusNode = FocusNode();
@override
void initState() {
super.initState();
// Request focus after the widget is built
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
}
TextField(
focusNode: _focusNode,
decoration: const InputDecoration(labelText: 'Email'),
)
// Trap focus inside a modal dialog
FocusTrap(
focusScopeNode: FocusScopeNode(),
child: AlertDialog(...),
)
Text scaling
Users with low vision increase text size in system settings. Test that your layouts don't break:
// Avoid fixed-height containers that clip large text
Container(
height: 44, // Will clip when text is 200% scale
child: Text('Label'),
)
// Prefer intrinsic sizing
Container(
padding: const EdgeInsets.all(8),
child: Text('Label'),
)
// Cap text scale for small decorative text only
Text(
'BADGE',
textScaler: TextScaler.linear(
MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3),
),
)
Color contrast
WCAG AA requires 4.5:1 contrast ratio for normal text, 3:1 for large text. Test with:
# Flutter's accessibility checker
flutter test --tags=accessibility
Or use the Accessibility Inspector in Xcode (iOS) or adb commands for Android.
Reduced motion
final reduceMotion = MediaQuery.of(context).disableAnimations;
AnimatedOpacity(
duration: reduceMotion
? Duration.zero
: const Duration(milliseconds: 300),
opacity: isVisible ? 1.0 : 0.0,
child: child,
)
Testing accessibility
testWidgets('checkout button has correct semantics', (tester) async {
await tester.pumpWidget(const CheckoutButton());
final semantics = tester.getSemantics(find.byType(CheckoutButton));
expect(semantics.label, 'Proceed to checkout');
expect(semantics.hasAction(SemanticsAction.tap), true);
});
Enable the semantics checker in your test suite:
setUp(() {
tester.binding.ensureSemantics();
});
Common pitfalls
Relying only on color to convey information. "Red means error, green means success" fails for color-blind users. Always pair color with an icon or text.
Ignoring focus order. Screen reader users navigate by tab/swipe. If focus jumps around the screen non-linearly, the app is confusing. Widgets receive focus in widget-tree order — restructure the tree if needed.
ExcludeSemantics abuse. Using ExcludeSemantics to hide elements from screen readers removes them from the accessibility tree entirely — the user can never access them. Use it only for purely decorative elements.
Sign in to like, dislike, or report.