DEV Community

kanta13jp1
kanta13jp1

Posted on

Flutter Accessibility: Semantics, Screen Readers, and WCAG

Flutter Accessibility: Semantics, Screen Readers, and WCAG

Accessibility isn't a special feature — it's a quality standard.

Semantics: Feeding Screen Readers

// Bad: icon button with no meaning for screen readers
IconButton(
  icon: const Icon(Icons.favorite),
  onPressed: () => _toggleFavorite(),
);

// Good: tooltip becomes the semantic label automatically
IconButton(
  icon: const Icon(Icons.favorite),
  onPressed: () => _toggleFavorite(),
  tooltip: 'Add to favorites',
);

// Custom widgets need explicit Semantics
Semantics(
  label: 'Progress: 75%',
  value: '75',
  child: LinearProgressIndicator(value: 0.75),
);

// Decorative images should be excluded
Image.asset(
  'assets/decorative_banner.png',
  excludeFromSemantics: true,
);
Enter fullscreen mode Exit fullscreen mode

Focus Management

class _LoginFormState extends State<LoginForm> {
  final _emailFocus = FocusNode();
  final _passwordFocus = FocusNode();

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

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        TextField(
          focusNode: _emailFocus,
          textInputAction: TextInputAction.next,
          onSubmitted: (_) => _passwordFocus.requestFocus(),
          decoration: const InputDecoration(label: Text('Email')),
        ),
        TextField(
          focusNode: _passwordFocus,
          textInputAction: TextInputAction.done,
          obscureText: true,
          decoration: const InputDecoration(label: Text('Password')),
        ),
      ],
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Color Contrast: WCAG AA Compliance

// WCAG AA: normal text ≥ 4.5:1 / large text ≥ 3:1

// Bad: insufficient contrast
Text(
  'Helper text',
  style: TextStyle(color: Color(0xFFAAAAAA)),  // ~2.3:1 on white
);

// Good: passes AA
Text(
  'Helper text',
  style: TextStyle(color: Color(0xFF767676)),  // 4.54:1 on white
);

// React to high-contrast system setting
final features = MediaQuery.of(context).accessibilityFeatures;
if (features.highContrast) {
  // apply high-contrast theme
}
Enter fullscreen mode Exit fullscreen mode

Touch Targets: Minimum 48×48 dp

// Bad: too small to tap reliably
InkWell(
  onTap: () {},
  child: const Icon(Icons.close, size: 16),
);

// Good: ensure minimum tap area with SizedBox
SizedBox(
  width: 48,
  height: 48,
  child: InkWell(
    onTap: () {},
    child: const Center(
      child: Icon(Icons.close, size: 16),
    ),
  ),
);
Enter fullscreen mode Exit fullscreen mode

Testing Accessibility

flutter test --tags accessibility
# DevTools > Accessibility tab — inspect the Semantics tree
Enter fullscreen mode Exit fullscreen mode
testWidgets('login button has semantic label', (tester) async {
  await tester.pumpWidget(const MyApp());
  expect(
    tester.getSemantics(find.byType(ElevatedButton)),
    matchesSemantics(label: 'Login', isButton: true),
  );
});
Enter fullscreen mode Exit fullscreen mode

Summary

Semantics     → label/value to define what screen readers announce
Focus         → FocusNode + textInputAction for correct Tab order
Contrast      → 4.5:1 normal / 3:1 large text (WCAG AA)
Touch targets → minimum 48×48 dp (Material Design spec)
Enter fullscreen mode Exit fullscreen mode

Nothing beats testing with VoiceOver (iOS) or TalkBack (Android) on a real device.

Top comments (0)