Flutter Lesson 71 of 83 5 min read
Accessibility in Flutter
Make Flutter apps usable by everyone: screen reader labels with Semantics, tap target sizes, contrast, text scaling and testing with TalkBack.
On this page
Roughly one person in six lives with a disability. Many more are in a temporary situation that has the same effect: bright sunlight, one hand busy, a cracked screen, small text without their glasses. An accessible app works better for all of them, and in many countries it is a legal requirement.
Flutter’s built-in widgets do much of the work. Your job is mostly to not undo it, and to fill a few gaps.
The four areas #
| Area | Question |
|---|---|
| Screen readers | Can someone who cannot see the screen use the app? |
| Size and touch | Can someone with limited dexterity hit the controls? |
| Vision | Is text readable with low vision or colour blindness? |
| Motion and time | Can people who are sensitive to motion, or slower, still use it? |
Screen readers #
TalkBack on Android and VoiceOver on iOS read the screen aloud and let users move between elements by swiping. Flutter builds a semantics tree that describes each element to them.
Text, buttons, checkboxes, switches and text fields already describe themselves. The gaps are images, icons and custom widgets.
// An icon button: the tooltip is read aloud.
IconButton(
icon: const Icon(Icons.delete),
tooltip: 'Delete message',
onPressed: _delete,
)
// A meaningful image
Image.network(url, semanticLabel: 'Photo of Phewa Lake at sunrise')
// A decorative image: hide it from the reader
Image.asset('assets/divider.png', excludeFromSemantics: true)
// A meaningful icon
const Icon(Icons.verified, semanticLabel: 'Verified seller')
The Semantics widget #
For custom widgets, describe them yourself.
Semantics(
button: true,
label: 'Add to favourites',
onTap: _toggleFavourite,
child: GestureDetector(
onTap: _toggleFavourite,
child: const HeartAnimation(),
),
)
// A rating drawn with five star icons
Semantics(
label: 'Rated 4 out of 5 stars',
child: ExcludeSemantics(child: StarRow(rating: 4)),
)
| Property | Tells the reader |
|---|---|
label | What it is |
value | Its current value, such as “40 percent” |
hint | What happens if you activate it |
button, header, image, link | Its role |
selected, checked, enabled | Its state |
liveRegion: true | Announce changes automatically, for status messages |
Grouping #
A card with a title, a price and a rating is read as three separate stops. Merge them so they are read as one.
MergeSemantics(
child: ListTile(
leading: const Icon(Icons.shopping_bag),
title: const Text('Trail backpack'),
subtitle: const Text('Rs. 3,499'),
),
)
ListTile already merges its own parts. Use MergeSemantics for rows you build yourself, and ExcludeSemantics to hide purely decorative content.
Mark section titles so users can jump between them:
Semantics(header: true, child: Text('Your orders', style: theme.textTheme.titleLarge))
Tap targets #
Small targets are hard to hit for everyone, and impossible for some. The minimum is 48 by 48 logical pixels on Android and 44 by 44 on iOS.
Material buttons already meet this. Watch for your own tappable widgets:
// Too small: a 20-pixel icon in a GestureDetector
GestureDetector(onTap: _close, child: const Icon(Icons.close, size: 20))
// Right: IconButton pads the target to 48 pixels
IconButton(onPressed: _close, icon: const Icon(Icons.close, size: 20), tooltip: 'Close')
Leave space between targets, so a tap does not hit the neighbour.
Contrast #
Text must stand out from its background. The standard is a contrast ratio of 4.5 to 1 for normal text and 3 to 1 for large text.
- Use
ColorScheme.fromSeedand pair each colour with itson...partner (onPrimaryonprimary). These pairs are designed to pass. - Light grey text on white is the most common failure.
- Check both the light and the dark theme.
Do not rely on colour alone #
About one man in twelve has some colour blindness. “Red means error” is invisible to many of them.
// Colour only
Text('Payment failed', style: TextStyle(color: Colors.red))
// Colour, an icon and words
Row(
children: [
Icon(Icons.error_outline, color: Theme.of(context).colorScheme.error),
const SizedBox(width: 8),
const Expanded(child: Text('Payment failed. Check your card details.')),
],
)
The same applies to charts, status dots and required form fields.
Text scaling #
Users can enlarge text in their device settings, up to twice the size or more. Flutter scales Text automatically. Layouts break when you assume a size.
- Do not put text in a box of fixed height.
- Let text wrap. Use
Expandedin rows. - Make screens scrollable.
- Test at the largest setting.
// Simulate large text while developing
MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: const TextScaler.linear(2)),
child: const MyScreen(),
)
Do not disable scaling to protect a design. If one element truly cannot grow, such as a label inside a tab bar, limit it for that element only with MediaQuery.withClampedTextScaling.
Forms #
- Every field has a
labelText. A hint alone disappears when the user types. - Error messages say what is wrong and how to fix it, in words.
- The keyboard’s “next” key moves through the fields in order.
Motion #
final reduceMotion = MediaQuery.disableAnimationsOf(context);
When this is true, shorten or skip non-essential animations. Avoid flashing content altogether.
Keyboard and focus #
On desktop, web and with accessibility switches, people move with the Tab key. Material widgets are focusable already and show a focus highlight. For custom widgets, use InkWell or wrap them in Focus and FocusableActionDetector, and check that the tab order follows the visual order.
Testing #
By hand. Turn on TalkBack or VoiceOver and use your app with your eyes closed. Five minutes of this finds most problems. Then set the largest text size and try every screen.
With tools. Enable the semantics overlay to see what a screen reader sees:
MaterialApp(showSemanticsDebugger: true, home: const HomePage())
In tests. Flutter can check guidelines automatically.
testWidgets('meets accessibility guidelines', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(const MaterialApp(home: HomePage()));
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
await expectLater(tester, meetsGuideline(iOSTapTargetGuideline));
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
await expectLater(tester, meetsGuideline(textContrastGuideline));
handle.dispose();
});
Checklist #
- Every icon button has a tooltip.
- Meaningful images have a label, and decorative ones are excluded.
- Tap targets are at least 48 pixels.
- Text contrast passes in light and dark themes.
- Nothing is conveyed by colour alone.
- The app works at double text size.
- Every screen can be used with a screen reader.
Try it yourself #
Take one screen you have built. Turn on the screen reader and go through it. Fix every unlabelled control, merge rows that are read in pieces, and add the guideline test above until it passes.