Skip to content
ExponaBLOG
FLUTTER · DAY 072 / 100

Accessibility in Flutter: a hands-on guide for engineers

Apps everyone can use.

Expona Admin9 min read

Introduction

Building an accessible app is not a feature you add at the end—it's a foundation you build from day one. Accessibility in Flutter: a hands-on guide for engineers addresses the practical barriers that exclude millions of users, whether they're navigating with a screen reader, scaling text to readable sizes, or relying on high-contrast colors. When we prioritize accessibility, we're not just expanding our user base; we're acknowledging that good engineering means considering everyone.

Over the next few sections, we'll walk through semantics, text scaling, contrast, and screen reader testing. These aren't theoretical concerns—they're concrete problems that real users face every time they open your app.

Semantics — Labels for icons and custom widgets

Screen reader users don't see your beautifully designed icon. They need a label that tells them what it does. That's where semantics come in. Flutter's semantic layer bridges the visual design and assistive technology, ensuring that every interactive element has a meaningful description.

Most of the time, Flutter widgets handle semantics automatically. A button labeled "Sign in" already announces its label to TalkBack or VoiceOver. But custom widgets, icon buttons, and images need explicit help.

Let's say you've built a custom star rating widget:

dart
class StarRating extends StatelessWidget {
  final int rating;
  final VoidCallback onChanged;

  const StarRating({required this.rating, required this.onChanged});

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onChanged,
      child: Row(
        children: List.generate(5, (index) {
          return Icon(
            index < rating ? Icons.star : Icons.star_outline,
            color: Colors.amber,
          );
        }),
      ),
    );
  }
}

A screen reader user will hear nothing meaningful here. The GestureDetector doesn't announce itself, and the icons are silent. Wrap it with Semantics to fix this:

dart
@override
Widget build(BuildContext context) {
  return Semantics(
    label: 'Rating: $rating out of 5 stars',
    button: true,
    onTap: onChanged,
    child: GestureDetector(
      onTap: onChanged,
      child: Row(
        children: List.generate(5, (index) {
          return Semantics(
            enabled: true,
            child: Icon(
              index < rating ? Icons.star : Icons.star_outline,
              color: Colors.amber,
            ),
          );
        }),
      ),
    ),
  );
}

For a simple icon button without a label prop, use Semantics.button:

dart
Semantics(
  button: true,
  enabled: true,
  label: 'Open menu',
  onTap: () => openMenu(),
  child: GestureDetector(
    onTap: () => openMenu(),
    child: Icon(Icons.menu),
  ),
)

For images that convey meaning, use Semantics.image. If an image is purely decorative, set enabled to false so the screen reader skips it:

dart
Semantics(
  image: true,
  label: 'Product photo: blue ceramic mug',
  child: Image.network('https://example.com/mug.jpg'),
)

// Decorative image
Semantics(
  enabled: false,
  child: Image.asset('decorative_background.png'),
)

Test your labels by enabling TalkBack (Android) or VoiceOver (iOS) and navigating through your app. Listen for clarity. "Rating: 4 out of 5 stars" is helpful; "Button enabled" is not.

Text scaling — Layouts must survive large font sizes

Users can adjust their system text size in device settings. On Android, some users bump it to 200% or even higher. On iOS, Dynamic Type allows apps to scale text automatically. If your layout assumes a fixed text size, it will break.

The most common mistake is using fixed-height containers or single-line text constraints. Let's look at a card-based design that fails when text scales:

dart
class ProductCard extends StatelessWidget {
  final String title;
  final String price;

  const ProductCard({required this.title, required this.price});

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 100,
      padding: EdgeInsets.all(12),
      child: Column(
        children: [
          Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
          Text(price, style: Theme.of(context).textTheme.bodyMedium),
        ],
      ),
    );
  }
}

At large font sizes, the title text will truncate or overflow. The Container's fixed height won't expand. Fix this by removing fixed heights and allowing the widget to size itself:

dart
@override
Widget build(BuildContext context) {
  return Card(
    child: Padding(
      padding: EdgeInsets.all(12),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            title,
            style: Theme.of(context).textTheme.titleMedium,
            maxLines: 2,
            overflow: TextOverflow.ellipsis,
          ),
          SizedBox(height: 8),
          Text(
            price,
            style: Theme.of(context).textTheme.bodyMedium,
          ),
        ],
      ),
    ),
  );
}

Notice the changes: no fixed height, mainAxisSize set to min so the Column doesn't force unnecessary space, and maxLines set to 2 (more graceful than 1). Text scaling will now push the card taller naturally.

Use MediaQuery to detect when the user has scaled text beyond normal and adjust layouts proactively:

dart
final textScaleFactor = MediaQuery.of(context).textScaleFactor;
if (textScaleFactor > 1.5) {
  // Use a single-column layout instead of grid
  return Column(children: items);
} else {
  // Use the default grid layout
  return GridView.count(
    crossAxisCount: 2,
    children: items,
  );
}

Test on your device: go to Settings > Display > Text size (Android) or Settings > Accessibility > Display > Text Size (iOS) and set it to maximum. Navigate through your entire app. No truncation, no overflow, no content loss. That's the standard.

Contrast — Check colors in both light and dark themes

Low contrast between text and background makes it hard for users with low vision or color blindness to read. The WCAG standard requires a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text. On dark modes and light modes, these ratios shift, and what works in light mode might fail in dark mode.

A common mistake is choosing colors based on visual appeal alone, without measuring contrast:

dart
// Light gray text on light background — don't do this
Text(
  'Subtle hint text',
  style: TextStyle(
    color: Color(0xFFCCCCCC),
    backgroundColor: Color(0xFFF5F5F5),
  ),
)

That contrast ratio is roughly 1.5:1, well below the 4.5:1 requirement. Users with low vision or astigmatism won't be able to read it reliably.

Use a tool like WebAIM Contrast Checker or Stark (available as a Figma plugin) to measure contrast. Always check both your light and dark themes. Here's a more accessible alternative:

dart
// Better: Darker text with more distance from background
Text(
  'Subtle hint text',
  style: TextStyle(
    color: Color(0xFF666666),  // Darker gray
    backgroundColor: Color(0xFFF5F5F5),
  ),
)

// Or in dark mode:
Text(
  'Subtle hint text',
  style: TextStyle(
    color: Color(0xFFBBBBBB),  // Lighter gray
    backgroundColor: Color(0xFF1A1A1A),
  ),
)

Build your color system with accessibility in mind from the start. Define primary, secondary, and tertiary text colors with sufficient contrast against their backgrounds. In your theme:

dart
final lightTheme = ThemeData(
  brightness: Brightness.light,
  textTheme: TextTheme(
    bodyMedium: TextStyle(color: Color(0xFF1F1F1F)), // Dark gray on light bg
    labelSmall: TextStyle(color: Color(0xFF666666)), // Medium gray
  ),
);

final darkTheme = ThemeData(
  brightness: Brightness.dark,
  textTheme: TextTheme(
    bodyMedium: TextStyle(color: Color(0xFFF5F5F5)), // Light gray on dark bg
    labelSmall: TextStyle(color: Color(0xFFAAAAAA)), // Medium gray
  ),
);

Don't rely on color alone to convey meaning. If you use red to indicate an error, also include an error icon or label. Some users can't distinguish red from other colors. Same rule applies to status indicators: use color plus shape or text.

Screen readers — Test with TalkBack and VoiceOver

TalkBack (Android) and VoiceOver (iOS) are the gold standard for accessible navigation. They don't just read labels—they guide users through your app's structure, announce state changes, and help users understand what's interactive. Testing with a screen reader is non-negotiable.

Enable TalkBack: Go to Settings > Accessibility > TalkBack on Android. Toggle it on. Your device will now speak every element you tap. Try swiping right to navigate forward and left to go back. Enable it on a real device, not the emulator, for a proper feel.

Enable VoiceOver: Go to Settings > Accessibility > VoiceOver on iOS. Toggle it on. Tap once to select, double-tap to activate, and swipe right to move forward.

When testing, focus on announcement order. Does the screen reader announce elements in a logical reading order? In Flutter, Semantics respects the order of widgets in your widget tree. If your UI has an image on the right and text on the left visually, but text comes first in your widget tree, the screen reader will announce text first. Rearrange your widget tree to match the logical reading order:

dart
// Poor: Visual and semantic order don't match
Row(
  children: [
    Image.asset('product.jpg'),
    Column(
      children: [
        Text('Product Name'),
        Text('$99.99'),
      ],
    ),
  ],
)

// Better: Reorder for logical flow
Row(
  children: [
    Expanded(
      child: Column(
        children: [
          Text('Product Name'),
          Text('$99.99'),
        ],
      ),
    ),
    Image.asset('product.jpg'),
  ],
)

Announce state changes. When a user toggles a switch or completes an action, the screen reader should announce the new state:

dart
bool isEnabled = false;

void toggleSwitch() {
  isEnabled = !isEnabled;
  SemanticsService.announce(
    isEnabled ? 'Notifications enabled' : 'Notifications disabled',
  );
}

Test custom gestures. If your app uses swipe or long-press gestures, ensure screen reader users can still interact. Provide alternative tap targets:

dart
GestureDetector(
  onLongPress: () => showMenu(),
  child: Semantics(
    button: true,
    label: 'Options',
    onTap: () => showMenu(),
    child: ListTile(title: Text('Item')),
  ),
)

Spend at least 15 minutes navigating your app with the screen reader on. You'll quickly spot gaps in labels, confusing announcement order, and missing state updates.

Common mistakes

  • Icon-only buttons without semantics. If you use Icons.menu without a label, add a Semantics widget with a label prop. Don't assume the icon's visual meaning is obvious to a screen reader.
  • Fixed heights and widths. They fail when text scales. Use flexible containers, Column with mainAxisSize.min, and let content drive size.
  • Testing only in light mode. Dark mode changes contrast ratios significantly. Test both. Some users enable dark mode for eye comfort.
  • Skipping screen reader testing. Automation can catch missing labels, but only real testing reveals confusing announcement order, unclear state changes, and broken gestures.
  • Using color alone for status. Red doesn't mean much to someone with color blindness. Add icons, labels, or patterns to reinforce meaning.
  • Nested interactive elements. Don't put a button inside another button. Screen readers get confused, and gestures fail. Flatten your hierarchy.

Key takeaways

  • Wrap icons and custom widgets with Semantics to provide meaningful labels for screen reader users.
  • Remove fixed heights and widths from containers; let widgets size based on content so text scaling doesn't break layouts.
  • Measure contrast ratios (target 4.5:1 for normal text) in both light and dark themes using tools like WebAIM.
  • Test with TalkBack and VoiceOver on real devices, focusing on announcement order, state changes, and gesture alternatives.
  • Accessibility isn't a feature checklist—it's a design principle that influences every decision from color selection to widget hierarchy.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles