Lesson 46 of 77 intermediate

Accessibility: Semantics, Screen Readers & Focus

Semantics widget, TalkBack/VoiceOver testing, focus order, WCAG compliance in Flutter

Open interactive version (quiz + challenge)

Real-world analogy

Accessibility is like building a physical space: the widgets you see are the visual architecture, but Semantics is the tactile map you hand to someone navigating with a cane (screen reader). Without it, they experience your beautiful building as a featureless wall. A 48x48 tap target is like a door handle that's reachable — too small and nobody can open the door.

What is it?

Accessibility ensures your app is usable by people with disabilities — visual impairments, motor limitations, cognitive differences. It is also legally required in many jurisdictions (ADA in the US, EAA in the EU) for apps in public-facing sectors. Senior Flutter engineers are expected to design for and test accessibility from the start.

Real-world relevance

The NGO survey app for field workers: survey questions must be completable via TalkBack for workers with low vision. Each question's Semantics label includes the question number and type. Radio buttons use MergeSemantics to combine the question text with the answer. Decorative country flag images use ExcludeSemantics. Form submit button has a minimum 48x48 tap target enforced by SizedBox.

Key points

Code example

// === CUSTOM ACCESSIBLE CARD ===
class ClaimCard extends StatelessWidget {
  final Claim claim;
  final VoidCallback onTap;
  const ClaimCard({required this.claim, required this.onTap, super.key});

  @override
  Widget build(BuildContext context) {
    return Semantics(
      label: 'Claim ${claim.id}',
      hint: 'Double tap to view claim details',
      button: true,
      enabled: true,
      child: GestureDetector(
        onTap: onTap,
        child: MergeSemantics(
          // Merges icon + status text into one announcement
          child: Row(children: [
            ExcludeSemantics(
              child: Icon(Icons.receipt, color: Theme.of(context).colorScheme.primary),
            ),
            const SizedBox(width: 12),
            Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
              Text('Claim #${claim.id}',
                  style: Theme.of(context).textTheme.titleMedium),
              Text(claim.status,
                  style: Theme.of(context).textTheme.bodySmall),
            ]),
          ]),
        ),
      ),
    );
  }
}

// === TAP TARGET SIZE ENFORCEMENT ===
class AccessibleIconButton extends StatelessWidget {
  final IconData icon;
  final VoidCallback onPressed;
  final String semanticLabel;

  const AccessibleIconButton({
    required this.icon, required this.onPressed,
    required this.semanticLabel, super.key,
  });

  @override
  Widget build(BuildContext context) {
    return Semantics(
      label: semanticLabel,
      button: true,
      child: InkWell(
        onTap: onPressed,
        borderRadius: BorderRadius.circular(24),
        child: const SizedBox(
          width: 48, height: 48,  // WCAG minimum tap target
          child: Center(child: Icon(icon)),
        ),
      ),
    );
  }
}

// === FOCUS ORDER CONTROL ===
class SurveyForm extends StatefulWidget {
  @override
  State<SurveyForm> createState() => _SurveyFormState();
}

class _SurveyFormState extends State<SurveyForm> {
  final _nameFocus = FocusNode();
  final _emailFocus = FocusNode();
  final _submitFocus = FocusNode();

  @override
  void dispose() {
    _nameFocus.dispose(); _emailFocus.dispose(); _submitFocus.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(children: [
      TextFormField(
        focusNode: _nameFocus,
        textInputAction: TextInputAction.next,
        onFieldSubmitted: (_) => _emailFocus.requestFocus(),
        decoration: const InputDecoration(labelText: 'Full name'),
      ),
      TextFormField(
        focusNode: _emailFocus,
        textInputAction: TextInputAction.done,
        onFieldSubmitted: (_) => _submitFocus.requestFocus(),
        decoration: const InputDecoration(labelText: 'Email address'),
      ),
      Semantics(
        focusable: true,
        child: Focus(
          focusNode: _submitFocus,
          child: ElevatedButton(
            onPressed: _submit,
            child: const Text('Submit survey'),
          ),
        ),
      ),
    ]);
  }
}

// === FLUTTER TEST — ACCESSIBILITY ===
testWidgets('ClaimCard announces correct semantics', (tester) async {
  await tester.pumpWidget(MaterialApp(
    home: ClaimCard(claim: mockClaim, onTap: () {}),
  ));

  expect(
    tester.getSemantics(find.byType(ClaimCard)),
    matchesSemantics(label: 'Claim 123', isButton: true, hasTapAction: true),
  );
});

Line-by-line walkthrough

  1. 1. Semantics(label: 'Claim 123', hint: 'Double tap to view', button: true) — provides all three key screen reader properties: what it is, what it does, and its role
  2. 2. ExcludeSemantics on the receipt icon — prevents the screen reader from announcing 'image' before the meaningful text content
  3. 3. MergeSemantics — makes the entire Row (icon + title + status) announce as one node instead of three separate announcements
  4. 4. SizedBox(width: 48, height: 48) — enforces minimum tap target size around small icons
  5. 5. FocusNode + textInputAction.next — chains keyboard focus between form fields in the correct logical order
  6. 6. onFieldSubmitted: (_) => _emailFocus.requestFocus() — programmatically moves focus when user presses the keyboard's next action
  7. 7. matchesSemantics matcher in widget test — verifies the semantic tree node, not the visual widget
  8. 8. tester.getSemantics(find.byType(ClaimCard)) — retrieves the SemanticsNode for the widget under test

Spot the bug

class StatusBadge extends StatelessWidget {
  final String status;
  const StatusBadge({required this.status, super.key});

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
      decoration: BoxDecoration(
        color: status == 'approved' ? Colors.green : Colors.red,
        borderRadius: BorderRadius.circular(12),
      ),
      child: Text(status, style: const TextStyle(color: Colors.white)),
    );
  }
}
Need a hint?
This widget has two accessibility issues — one about screen reader semantics and one about WCAG compliance. Find both.
Show answer
Bug 1 (Semantics): The Container + Text combination has basic text semantics but no role or context. A screen reader will announce the text 'approved' without context about what it means. Wrap with Semantics(label: 'Status: approved') to provide meaningful context. Bug 2 (Colour contrast): The widget communicates status ONLY through colour (green vs red). Users with colour blindness (affects ~8% of males) cannot distinguish these. WCAG 1.4.1 (Use of Color) requires that colour is not the only visual means of conveying information. Add an icon or text prefix: 'Approved' / 'Rejected' so the meaning is not colour-dependent. Additionally, white text on Colors.green (hex #4CAF50) may fail the 4.5:1 contrast ratio check.

Explain like I'm 5

Imagine someone is using your app but they can't see the screen — they hear a voice reading it to them. If you built a beautiful custom button from scratch but never added a label, the voice just says 'blank' — useless. Semantics is like putting a little sticky note on everything: 'this is a button called Submit.' MergeSemantics groups sticky notes together so the voice reads it as one thing instead of three confusing pieces.

Fun fact

Apple added VoiceOver to iPhone in 2009 — two years after the original iPhone launched with no accessibility features at all. It was announced in the same keynote as iPhone 3GS. Stevie Wonder called in to the WWDC keynote personally to thank Apple. Today iOS has the highest screen reader usage on mobile worldwide, with over 20% of iPhone users having at least one accessibility feature enabled.

Hands-on challenge

Audit the ClaimCard widget in the code sample for all accessibility issues. List: (1) what the screen reader announces without the Semantics wrapper, (2) why the receipt icon uses ExcludeSemantics, (3) what MergeSemantics changes about the announcement, (4) how you would test this on a physical device.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter Interview Mastery