Article

Accessibility Testing for Flutter with Widgetbook

Accessibility Testing for Flutter with Widgetbook

Flutter's flutter_test package ships four accessibility (a11y) guidelines. These guidelines cover only three of the 56 success criteria in Web Content Accessibility Guidelines (WCAG) 2.2 at conformance levels A and AA. Getting a Flutter app to WCAG AA is mostly manual work. When prioritizing accessibility and investing sufficient resources, most teams eventually reach a satisfying accessible level through hard, manual work. However, keeping the app WCAG compliant is a different problem. During the next audit, developers notice that they accidentally merged unintended accessibility changes that introduced accessibility violations. Their app silently loses the accessibility level they worked hard for. Widgetbook Cloud's accessibility regression testing helps you keep your app accessible.

Book a demo to see Widgetbook Cloud's accessibility regression testing in action.

This guide has three parts.

  1. Why a11y matters, and why it is now a legal requirement for some apps.

  2. Why accessibility testing is hard, and what the status quo of accessibility testing is in Flutter.

  3. How Widgetbook and Widgetbook Cloud help keep your app accessible.


Why accessibility matters

More people can use your product. The World Health Organization estimates that 1.3 billion people, about 16% of the world, live with a significant disability (study). Disability is also situational rather than only permanent. A broken wrist, a missing finger, or a phone held in one hand on a crowded train all change how someone uses an app.

Accessible design helps everyone, not only the people it was built for. Q42 analysed 1.5 million iOS users and found that a third of them change the text size on their phone, 20% larger and 13% smaller (study). That means roughly one user in five runs your interface at a text scale you don't test on every pull request. On most apps that is a bigger group than any locale you ship to, and they do not file bugs about truncated text. They just stop using the screen or even your app.

In Europe, some apps now have to be accessible by law. The European Accessibility Act has been applicable since 28 June 2025. It does not cover every app, but it does cover specific sectors including e-commerce, consumer banking, e-books, passenger transport, and telecommunications. If you build a banking app for the European market, you are in scope. Conformance is demonstrated against EN 301 549, which incorporates WCAG at Level AA.


Why Accessibility Testing is hard

Accessibility bugs are silent. A missing label doesn't throw an exception or fail a build. The app looks perfect, works perfectly for sighted users, and ships green through CI. The only symptom is that someone using a screen reader hits a button that announces nothing, and you never see a stack trace for that.

Automation only covers a fraction of the problem. Deque's analysis of nearly 300,000 real-world issues found that automated tools detect about 57% of issues by volume, but only 16 of the 50 WCAG success criteria can be automated at all. The rest require human judgment. For example, an automated test tool can verify that an image has alt text, but it cannot tell you that alt="image" is useless.

There is no single "correct" answer to test against. What a user actually hears depends on the assistive technology: TalkBack, VoiceOver, and NVDA each choose their own role names, ordering, verbosity, and behavior shifts between versions and user settings. The same UI can produce a fine experience on one screen reader and a confusing one on another, which makes it hard to even define what a passing test means.

What makes Flutter special

Native Android builds a view hierarchy that the operating system understands, with real Button and TextView objects, and TalkBack reads that hierarchy directly. Flutter does not work that way. It renders everything into a single drawing surface, so the operating system sees one opaque view. To make the app accessible, Flutter maintains a separate semantics tree in parallel, and an engine bridge translates that tree into the platform accessibility tree that TalkBack and VoiceOver read.

This has a few consequences that shape how you test:

  1. Custom widgets are not accessible by default. While Material and Cupertino widgets, like an ElevatedButton, have built-in accessibility support, a GestureDetector wrapped around a Container does forward its tap to the semantics tree, but with no label and no button role. So, the screen reader either skips it entirely or focuses something it can't describe.

  2. There is also no equivalent of the web's axe-core for Flutter. That means no established rule library and no published mapping from WCAG to Flutter APIs.

  3. The semantics tree your tests inspect is not the same thing as what the user hears. There are three representations in a row. Your Flutter code builds the semantics tree, which is the tree of SemanticsNodeobjects that flutter_test reads. The engine then translates that into the platform accessibility tree, meaning AccessibilityNodeInfo on Android and UIAccessibilityElement on iOS. Finally TalkBack or VoiceOver reads the platform tree and turns it into speech, adding its own role names, pauses, and grouping along the way.


meetsGuidelines - Flutter's automated a11y checks

The four guidelines live in flutter_test and run inside an ordinary widget test.

testWidgets('follows a11y guidelines', (tester) async {
  final handle = tester.ensureSemantics();
  await tester.pumpWidget(wrap(row));

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

  handle.dispose();
});
testWidgets('follows a11y guidelines', (tester) async {
  final handle = tester.ensureSemantics();
  await tester.pumpWidget(wrap(row));

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

  handle.dispose();
});
testWidgets('follows a11y guidelines', (tester) async {
  final handle = tester.ensureSemantics();
  await tester.pumpWidget(wrap(row));

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

  handle.dispose();
});

Guideline

Checks

Maps to

androidTapTarget

Tappable nodes at least 48×48 px

Android design guidelines

iOSTapTarget

Tappable nodes at least 44×44 px

Apple Human Interface Guidelines (HIG)

labeledTapTarget

Tappable nodes have a non-empty label

WCAG 4.1.2

textContrast

4.5:1, or 3:1 for large text

WCAG 1.4.3


What they catch on a real component

PaymentRequestRow renders one pending request. It has an avatar, the requester's name, a masked card number, a status badge, the amount, and a pair of circular accept and decline buttons. Those buttons ship with a deliberate violation so this article has something to catch: they are 32×32 and they carry no semantic label. Here is the first guideline run against the component.

const _mock = PaymentRequestRow(
  name: 'Alice Bergmann', last4: '4471', amount: '€50.00',
);

await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
const _mock = PaymentRequestRow(
  name: 'Alice Bergmann', last4: '4471', amount: '€50.00',
);

await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
const _mock = PaymentRequestRow(
  name: 'Alice Bergmann', last4: '4471', amount: '€50.00',
);

await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
Expected: Tappable objects should be at least Size(48.0, 48.0)
   Which: SemanticsNode#9(Rect.fromLTRB(711.0, 284.0, 743.0, 316.0),
          actions: [focus, tap], flags: [isButton, isFocusable]

Expected: Tappable objects should be at least Size(48.0, 48.0)
   Which: SemanticsNode#9(Rect.fromLTRB(711.0, 284.0, 743.0, 316.0),
          actions: [focus, tap], flags: [isButton, isFocusable]

Expected: Tappable objects should be at least Size(48.0, 48.0)
   Which: SemanticsNode#9(Rect.fromLTRB(711.0, 284.0, 743.0, 316.0),
          actions: [focus, tap], flags: [isButton, isFocusable]

Running all four across both Figma themes gives a precise verdict, and one result you might not expect.

Guideline

Light

Dark

androidTapTarget

fails, 32×32

fails, 32×32

iOSTapTarget

fails, 32×32

fails, 32×32

labeledTapTarget

fails, no label

fails, no label

textContrast

passes

passes

Contrast passes in both themes. It passes because the masked card line uses colors.textSecondary, a design token that is already AA, instead of a hardcoded grey. The design system stopped the bug before a test could find it. This is the cheapest work you can do and the work most teams skip. If you fix contrast and minimum target size in your tokens and your component APIs, whole categories of violation stop being possible rather than merely becoming detectable later.


The fix, also tested

A correct action button is a 48×48 tap target with a button role and a label that says what the control does. Wrapping the button in MergeSemantics combines the ink response and the Semantics widget into a single node, so the label, the button flag, and the tap action all end up on the same node the guidelines inspect.

MergeSemantics(
  child: Semantics(
    button: true,
    label: 'Accept €50.00 from Alice Bergmann',
    child: Material(
      color: colors.statusSuccessBg,
      shape: const CircleBorder(),
      child: InkWell(
        onTap: onAccept,
        customBorder: const CircleBorder(),
        child: SizedBox(
          width: 48, height: 48, // was 32×32
          child: Icon(Icons.check, size: 14, color: colors.statusSuccessText),
        ),
      ),
    ),
  ),
)
MergeSemantics(
  child: Semantics(
    button: true,
    label: 'Accept €50.00 from Alice Bergmann',
    child: Material(
      color: colors.statusSuccessBg,
      shape: const CircleBorder(),
      child: InkWell(
        onTap: onAccept,
        customBorder: const CircleBorder(),
        child: SizedBox(
          width: 48, height: 48, // was 32×32
          child: Icon(Icons.check, size: 14, color: colors.statusSuccessText),
        ),
      ),
    ),
  ),
)
MergeSemantics(
  child: Semantics(
    button: true,
    label: 'Accept €50.00 from Alice Bergmann',
    child: Material(
      color: colors.statusSuccessBg,
      shape: const CircleBorder(),
      child: InkWell(
        onTap: onAccept,
        customBorder: const CircleBorder(),
        child: SizedBox(
          width: 48, height: 48, // was 32×32
          child: Icon(Icons.check, size: 14, color: colors.statusSuccessText),
        ),
      ),
    ),
  ),
)



All four guidelines now pass, in both themes, and the button announces "Accept €50.00 from Alice Bergmann" instead of an ambiguous "Accept". No rule can make that distinction for you, but once you have decided on the wording, a getSemantics assertion can pin it so it does not quietly regress.


What they do not catch

Print the semantics tree with debugDumpSemanticsTree() and read the labels in order.




None of the four guidelines flag the decorative avatar that gets announced, the context-free "•• 4471", or the fact that one row is seven separate stops for a screen reader user. They miss these because they only inspect elements that are already accessible enough to appear in the tree at all. The clearest example is a whole row wrapped in a plain GestureDetector. It has no semantics node, so labeledTapTargetGuideline has nothing to iterate over, and the test stays green while the row is completely invisible to TalkBack. Reading the dump out loud in code review and asking whether you could dictate each label to a stranger over the phone catches more real bugs than any automated check in this article, and it belongs in the pull request rather than in a pre-release audit.


Accessibility violation detection and regressions with Widgetbook

Two problems tend to get conflated. Reaching a good a11y level is a detection problem, and in Flutter most of that work is manual because automated detection only reaches three criteria. Keeping that level is a regression problem, where the violations are already fixed, and you only need to know when changes appear again. Widgetbook helps with both, because a story is an isolated, named, parameterised UI state, and that makes it a place to run tests, for a single component or for a whole screen.


Accessibility violation detection across the mode matrix

A story already lists every meaningful state which are automatically tested as follows:

scenarios: [
  _Scenario(name: 'default',      args: _Args.fixed(name: 'Alice Bergmann', /*…*/)),
  _Scenario(name: 'long name',    args: _Args.fixed(name: 'Alexandria Featherstonehaugh-Montgomery', /*…*/)),
  _Scenario(name: 'large amount', args: _Args.fixed(amount: '€12,500.00', /*…*/)),
],
scenarios: [
  _Scenario(name: 'default',      args: _Args.fixed(name: 'Alice Bergmann', /*…*/)),
  _Scenario(name: 'long name',    args: _Args.fixed(name: 'Alexandria Featherstonehaugh-Montgomery', /*…*/)),
  _Scenario(name: 'large amount', args: _Args.fixed(amount: '€12,500.00', /*…*/)),
],
scenarios: [
  _Scenario(name: 'default',      args: _Args.fixed(name: 'Alice Bergmann', /*…*/)),
  _Scenario(name: 'long name',    args: _Args.fixed(name: 'Alexandria Featherstonehaugh-Montgomery', /*…*/)),
  _Scenario(name: 'large amount', args: _Args.fixed(amount: '€12,500.00', /*…*/)),
],
  1. Global addons are automatically tested across all scenarios. Here, the three data scenarios are tested across two themes and two device sizes. That is already dozens of rendered and checked states in a single flutter test run, with no device attached, and the four guidelines run in every one of those cells. That multiplication is what makes the four rules worth having.

  2. Contrast is checked in each theme. A grey that passes on the light card but fails on the dark one is caught, where a team that runs the contrast guideline once in one theme would miss it.

  3. Labels are checked in each locale. A missing translation key that renders an empty label fails labeledTapTargetGuideline in Arabic, on a commit nobody clicked through by hand.

  4. Tap targets are checked at each device size. An Expanded that squeezes the buttons on a narrow 320px screen is caught even though it looks fine at 430px.

  5. Text overflow is caught too. Rendering the long name scenario at a text scale of 2.0 throws a RenderFlex overflowed error without needing any accessibility rule at all, and that overflow affects the one-in-five users who increased their text size.


These tests can be run by developers and coding agents. As shown in our agentic UI engineering guide, Widgetbook 4 gives coding agents a self-healing UI feedback loop.


Accessibility Regression Tests

Widgetbook Cloud runs Accessibility Regression Tests, which automatically detect every accessibility change in your pull request. Accessibility Regression Tests capture the semantics tree for every scenario and diff it against the previous build, right next to the visual diff, and the two cover different failures. The visual diff catches what changed on the widget or screen. The semantics diff catches what changed for assistive technology, which is usually invisible on screen.

While violation detection reaches only three WCAG criteria, regression reaches every criterion you have already satisfied, because a diff does not need a rule. It does not have to know what a correct label, a correct focus order, or a correct heading structure looks like. It only has to notice that the tree changed.

Here, you can see an example:

You fixed the accept button by adding a label, but six weeks later someone refactors the button, and the label is dropped. The render is pixel-identical, so the visual diff sees nothing, but the label is gone, and the semantics diff shows it on Widgetbook Cloud alongside the accessibility violations. The same holds if a refactor drops a Semantics wrapper, merges two nodes, removes a header flag, or reorders traversal.

Widgetbook Cloud does not get your app to AA automatically. You need to invest the manual work. But once the app is accessible, the four guidelines together with the semantics and visual regression tests make sure it stays accessible. An accessibility bug can no longer ship quietly on an unrelated pull request, which is how nearly all of them ship today.

Book a demo to see Widgetbook Cloud's accessibility regression testing in action.


What our users say

We've been building our accessibility testing features in close collaboration with our users. One of them is Manuela Sakura Rommel. She is an Accessibility Consultant and helped multiple companies build a WCAG-AA-compliant app. In 2023, when accessibility was still an afterthought for most companies, she already shared how Flutter developers can build an accessible app on the Observable Flutter Show. Here, you can find the recording.

Apps change, Flutter changes, and every change can quietly break accessibility. Widgetbook Cloud is how my clients keep the level they worked hard for.

- Manuela Sakura Rommel, Accessibility Consultant for Flutter apps


Shift-Left Accessibility Testing Workflow

Our goal is to shift left in accessibility testing to catch a11y issues as early as possible, which results in:

  • Quick and cheap iterations early. The earlier the violation is detected, the easier it is to fix. You avoid costly fixes later.

  • Faster release cycles as issues are detected early and fixed quickly

  • Better user experience and higher user trust as issues are caught before they are shipped

Here is a brief, holistic accessibility testing workflow that you can use to create or improve your own process:

  1. At the design stage, designers bake accessibility into your tokens and component APIs. Encode strong contrast ratios and minimum target sizes, scalable text options, and generous spacing so layouts stay readable for users with low vision. Make sure to annotate reading order and heading levels in Figma, and where a single layout can't serve everyone, plan tailored variants for specific user groups instead of forcing a one-size-fits-all solution. Here, you can find a detailed guide by Figma on how to design for accessibility.

  2. In static analysis, you can add a custom_lint rule that flags when no semantic label is set.

  3. Locally, you build and catalog your widgets with Widgetbook. Run the component and screen scenarios through the four guidelines across the mode matrix.

  4. On every PR, Widgetbook Cloud automatically catches accessibility regressions to ensure that no unintended accessibility changes and violations slip through. Especially for teams that are already WCAG AA compliant and want to keep their app accessible, accessibility regression tests are powerful as they catch all accessibility changes automatically.

  5. For teams who are not WCAG-compliant yet and are not aware of all their accessibility violations: You still need to run manual screen reader tests to catch the accessibility violations that automated tests can't catch. Examples include semantic labels that are set but don't make sense, or keyboard navigation.

  6. Accessibility is not only a design and engineering challenge for your product. Every customer-facing team in an organization should take accessibility into consideration. For example, marketing needs to ensure that their ads and content are accessible too.


Summary

Flutter's four guidelines cover three of 56 WCAG criteria and miss the most common Flutter accessibility bug. Reaching WCAG AA is therefore mostly manual, but the two hard parts are separable. Detection of the automatable slice runs continuously via Widgetbook and flutter test, because the four guidelines execute in every cell of the theme, locale, text-scale, and device matrix. Regression needs no rules at all, because Widgetbook Cloud's semantics-tree diff flags the moment a refactor drops or changes a label, merges a node, or removes a header flag, which the visual diff cannot see. Widgetbook Cloud does not get your app to AA on its own, but it does stop it from regressing once you have done the manual work to get there.

Get Started

Start with the open-source package

Widgetbook is open source and free. Get started on pub.dev.

Get Started

Start with the open-source package

Widgetbook is open source and free. Get started on pub.dev.

Get Started

Start with the open-source package

Widgetbook is open source and free. Get started on pub.dev.