← Articles

Accessibility in Flutter apps

By John · 4 December 2025

Flutter has strong accessibility built in, but it requires intentional effort to make apps truly usable for people with visual, motor, or cognitive disabilities. This guide covers the practical implementation — from semantic labels to focus management.

Understanding the Semantics layer

Flutter renders pixels; the OS reads a parallel Semantics tree to communicate with accessibility services (TalkBack on Android, VoiceOver on iOS). Most Flutter widgets build their semantic meaning automatically, but many need help.

Test your app with screen readers on — Android TalkBack and iOS VoiceOver — to catch gaps that visual inspection won't reveal.

Semantic labels for images and icons

// BAD: VoiceOver says "image"
Image.network(product.imageUrl)

// GOOD: VoiceOver says "Blue Running Shoe, size 42"
Image.network(
  product.imageUrl,
  semanticLabel: '${product.name}, size ${product.size}',
)

// Icon without visible label: needs tooltip or semanticLabel
IconButton(
  icon: const Icon(Icons.shopping_cart),
  onPressed: onCartTap,
  tooltip: 'Shopping cart', // This becomes the semantic label
)

// Decorative images: exclude from semantics
Image.network(
  backgroundUrl,
  semanticLabel: '', // Empty string = excluded from semantics
  excludeFromSemantics: true,
)

Semantics widget

Wrap custom widgets to provide meaning:

// Price tag that combines multiple text widgets into one semantic element
Semantics(
  label: 'Price: \$${product.price}', // What screen reader announces
  child: Row(
    children: [
      const Text('\$', style: TextStyle(fontSize: 14)),
      Text(product.price.toStringAsFixed(2), style: const TextStyle(fontSize: 24)),
    ],
  ),
)

// Interactive element with custom action description
Semantics(
  button: true,
  label: 'Like this post',
  hint: 'Double tap to like',
  onTap: onLike,
  child: const LikeButton(),
)

Accessibility for custom interactive widgets

class StarRating extends StatelessWidget {
  const StarRating({super.key, required this.rating, this.onChanged});
  final double rating;
  final ValueChanged<double>? onChanged;

  @override
  Widget build(BuildContext context) {
    return Semantics(
      label: 'Rating: ${rating.toStringAsFixed(1)} out of 5 stars',
      // If interactive:
      slider: onChanged != null,
      value: rating.toString(),
      increasedValue: (rating + 0.5).clamp(0, 5).toString(),
      decreasedValue: (rating - 0.5).clamp(0, 5).toString(),
      onIncrease: onChanged != null ? () => onChanged!(rating + 0.5) : null,
      onDecrease: onChanged != null ? () => onChanged!(rating - 0.5) : null,
      child: Row(
        children: List.generate(5, (i) {
          return Icon(
            i < rating ? Icons.star : Icons.star_border,
            color: Colors.amber,
          );
        }),
      ),
    );
  }
}

Focus management

When a modal opens, focus should move to it. When it closes, focus should return to the element that opened it.

// Move focus to a specific element
final _focusNode = FocusNode();

@override
void initState() {
  super.initState();
  // Request focus after the widget is built
  WidgetsBinding.instance.addPostFrameCallback((_) {
    _focusNode.requestFocus();
  });
}

TextField(
  focusNode: _focusNode,
  decoration: const InputDecoration(labelText: 'Email'),
)

// Trap focus inside a modal dialog
FocusTrap(
  focusScopeNode: FocusScopeNode(),
  child: AlertDialog(...),
)

Text scaling

Users with low vision increase text size in system settings. Test that your layouts don't break:

// Avoid fixed-height containers that clip large text
Container(
  height: 44, // Will clip when text is 200% scale
  child: Text('Label'),
)

// Prefer intrinsic sizing
Container(
  padding: const EdgeInsets.all(8),
  child: Text('Label'),
)

// Cap text scale for small decorative text only
Text(
  'BADGE',
  textScaler: TextScaler.linear(
    MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3),
  ),
)

Color contrast

WCAG AA requires 4.5:1 contrast ratio for normal text, 3:1 for large text. Test with:

# Flutter's accessibility checker
flutter test --tags=accessibility

Or use the Accessibility Inspector in Xcode (iOS) or adb commands for Android.

Reduced motion

final reduceMotion = MediaQuery.of(context).disableAnimations;

AnimatedOpacity(
  duration: reduceMotion
      ? Duration.zero
      : const Duration(milliseconds: 300),
  opacity: isVisible ? 1.0 : 0.0,
  child: child,
)

Testing accessibility

testWidgets('checkout button has correct semantics', (tester) async {
  await tester.pumpWidget(const CheckoutButton());

  final semantics = tester.getSemantics(find.byType(CheckoutButton));
  expect(semantics.label, 'Proceed to checkout');
  expect(semantics.hasAction(SemanticsAction.tap), true);
});

Enable the semantics checker in your test suite:

setUp(() {
  tester.binding.ensureSemantics();
});

Common pitfalls

Relying only on color to convey information. "Red means error, green means success" fails for color-blind users. Always pair color with an icon or text.

Ignoring focus order. Screen reader users navigate by tab/swipe. If focus jumps around the screen non-linearly, the app is confusing. Widgets receive focus in widget-tree order — restructure the tree if needed.

ExcludeSemantics abuse. Using ExcludeSemantics to hide elements from screen readers removes them from the accessibility tree entirely — the user can never access them. Use it only for purely decorative elements.

Sign in to like, dislike, or report.

Accessibility in Flutter apps — ANN Tech