← All posts

6 min read

Accessibility in Flutter Apps: Semantics, Text Scaling and Automated Checks

A practical guide to accessibility in Flutter apps: Semantics labels, tap target sizes, text scaling, contrast, focus order, screen reader testing and guideline tests.

  • Flutter
  • Accessibility
  • UX
Cover illustration for Accessibility in Flutter Apps: Semantics, Text Scaling and Automated Checks

Turn on VoiceOver or TalkBack, close your eyes, and try to use your app for two minutes. For most apps, it goes something like this: “Button. Button. Image. Button.” Then you give up.

That’s not because Flutter is bad at accessibility. Flutter actually builds a semantics tree for you and gets a lot right by default. It’s because accessibility problems are invisible to anyone not using assistive technology, and nobody on the team is.

Here’s what I check on every Flutter app I build or take over, from the quick wins to the automated tests that keep them from regressing.

Semantics: what the screen reader actually sees

Flutter’s standard widgets already describe themselves. A Text is read aloud, an ElevatedButton with a text label is announced as a button with that label, a Switch reports whether it’s on. Problems start with custom widgets and icon-only controls.

Icon buttons need a tooltip. IconButton’s tooltip doubles as its semantic label:

IconButton(
  icon: const Icon(Icons.delete_outline),
  tooltip: 'Delete shift',
  onPressed: _delete,
);

Custom tappable widgets need Semantics. A GestureDetector wrapped around a Container is just a box to a screen reader:

Semantics(
  button: true,
  label: 'Open shift on Monday, 9 AM to 5 PM',
  child: GestureDetector(
    onTap: _openShift,
    child: ShiftCard(shift: shift),
  ),
);

Better still, use InkWell or a real button, which gives you semantics, focus and keyboard activation for free.

Merge and exclude to reduce noise. A list tile with an avatar, a name, a subtitle and a timestamp shouldn’t take four swipes to get through. MergeSemantics combines a subtree into one announcement, and ExcludeSemantics hides decorative pieces:

MergeSemantics(
  child: Row(
    children: [
      const ExcludeSemantics(child: CircleAvatar(child: Icon(Icons.person))),
      const SizedBox(width: 12),
      Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [Text(user.name), Text(user.role)],
      ),
    ],
  ),
);

Images. Meaningful images get a semanticLabel; decorative ones get excludeFromSemantics: true.

Write labels for the ear. “Btn_save” and “icon-trash” are what you get when developers write labels in a hurry. Labels should be short, describe the action or content, and not include the role (“Delete shift”, not “Delete shift button”); the screen reader adds the role itself.

Touch targets: 48dp and 44pt

Material recommends tap targets of at least 48×48dp, and Apple’s guidelines say 44×44pt. Small icons are fine visually as long as the tappable area meets that size.

Material buttons and IconButton already pad themselves to the minimum by default via MaterialTapTargetSize.padded. Watch out when you:

  • Set materialTapTargetSize: MaterialTapTargetSize.shrinkWrap or visualDensity: VisualDensity.compact everywhere to “tighten up” a design.
  • Build custom controls from GestureDetector on a 24px icon.
  • Put several small links tightly together in a row of text.

The fix is usually to keep the icon small and enlarge the hit area with padding or a SizedBox around an InkWell.

Text scaling: stop fixing heights

A lot of users increase their system font size. Flutter respects it by default, which means any layout that assumes text has a fixed size will break.

The usual culprits:

  • SizedBox(height: 48) around a Text or button label. The text grows and gets clipped.
  • maxLines: 1 with no overflow handling on text that matters.
  • Rows of labels that only fit at 100% scale.

Let containers size to their content, use minHeight constraints instead of fixed heights, and let rows wrap (Wrap, or switch to a column at large scales). If you need to know the scale for a layout decision, read it from MediaQuery:

final scaler = MediaQuery.textScalerOf(context);
final isLargeText = scaler.scale(16) > 24; // more than 150% at body size

return isLargeText
    ? Column(children: actions)
    : Row(children: actions);

TextScaler replaced the old textScaleFactor API, partly because Android 14 introduced nonlinear font scaling where large text grows less than small text. Use scaler.scale(fontSize) instead of multiplying by a factor yourself.

What I don’t do: clamp text scaling across the whole app with MediaQuery.withClampedTextScaling just to make the design hold. Occasionally clamping a single element, like a tab bar label, is a reasonable trade-off. Clamping everything takes away a setting users chose for a reason.

To test, I preview screens at large scales with a wrapper during development:

MediaQuery(
  data: MediaQuery.of(context).copyWith(textScaler: const TextScaler.linear(2.0)),
  child: const ScheduleScreen(),
);

The same flexible layouts help with responsive phone and tablet screens and with translated strings, which I cover in Flutter localization in practice.

Color contrast and color alone

WCAG asks for a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text. The usual offenders are light grey hint text, white text on a pastel brand color, and disabled-looking states that aren’t actually disabled.

Two practical rules:

  • Check contrast for your ColorScheme once, in both light and dark themes, rather than screen by screen. Material 3’s generated color schemes are a good starting point, though custom overrides can break them.
  • Never use color as the only signal. A red border on an invalid field should come with an error message; a green dot for “online” should have a text or semantic label too.

Focus and keyboard traversal

Keyboard and switch-control users move through your app by focus. Tablets with keyboards, Chromebooks and Flutter web make this more common than you’d think.

By default, Flutter traverses focus in reading order, which is usually right. When it isn’t, for example in a form laid out in two columns, use a FocusTraversalGroup with an explicit order:

FocusTraversalGroup(
  policy: OrderedTraversalPolicy(),
  child: Column(
    children: [
      FocusTraversalOrder(order: const NumericFocusOrder(1), child: firstNameField),
      FocusTraversalOrder(order: const NumericFocusOrder(2), child: lastNameField),
    ],
  ),
);

Also check that focus is visible (Material widgets show focus highlights; custom widgets need to handle it), that dialogs trap focus and return it when closed, and that every action reachable by tap is reachable by keyboard.

Test with the real screen readers

Automated checks catch the mechanical issues. Only a screen reader tells you whether the app makes sense.

  • TalkBack on Android: enable it in Accessibility settings, swipe right to move forward, double-tap to activate.
  • VoiceOver on iOS: same idea, toggled in Accessibility settings or with the Accessibility Shortcut (triple-click the side button) once you set it up.

Go through your main flows: sign in, the main task of the app, and checkout or whatever earns money. Listen for unlabeled buttons, announcements that read raw values (“2026-02-04T09:00”), reading order that jumps around, and dynamic changes (errors, loading finished) that are never announced. For that last one, Semantics(liveRegion: true) on a status message lets screen readers announce updates.

Automated guideline checks in widget tests

Flutter’s test framework includes accessibility guidelines you can assert against. I add one test per important screen:

testWidgets('schedule screen meets accessibility guidelines', (tester) async {
  final handle = tester.ensureSemantics();
  await tester.pumpWidget(const MaterialApp(home: ScheduleScreen()));

  await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
  await expectLater(tester, meetsGuideline(iOSTapTargetGuideline));
  await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
  await expectLater(tester, meetsGuideline(textContrastGuideline));

  handle.dispose();
});

labeledTapTargetGuideline catches tappable widgets without labels, the “Button. Button. Button.” problem. textContrastGuideline checks rendered text against its background. They don’t replace a screen reader pass, but they stop regressions from slipping in silently. I fit these into the broader setup described in my Flutter testing strategy.

It’s also good business

Accessibility gets framed as compliance, and in some markets and industries it is a legal requirement. But the business case is simpler than that: a lot of people use larger text, reduced motion, screen readers or switch controls, and many more are temporarily impaired, using a phone one-handed on a bus or in bright sunlight. Everything in this post makes the app easier for them too.

It’s also much cheaper to build in than to retrofit. Labeling a button as you write it takes seconds. Auditing and fixing a finished app takes days.

Takeaways

  • Give every icon-only and custom control a label via tooltip or Semantics; merge and exclude to cut noise.
  • Keep tap targets at least 48dp / 44pt, even when the icon is smaller.
  • Avoid fixed heights around text and use MediaQuery.textScalerOf and TextScaler for scale-aware layouts.
  • Meet contrast ratios and never rely on color alone.
  • Test main flows with TalkBack and VoiceOver, and add meetsGuideline checks to widget tests.

If you want an accessibility pass on an existing Flutter app, or want it built in from the start, get in touch.

Comments

Questions, corrections or your own experience — leave a comment below (GitHub sign-in).