Steven's Knowledge

Accessibility

Flutter accessibility — Semantics, screen readers, focus management, dynamic type, contrast, testing

Accessibility

Accessibility is not optional polish — it is a core engineering requirement. Flutter provides a single Semantics tree that maps to platform accessibility APIs on both iOS and Android, giving you one implementation path instead of two. This page covers everything needed to ship an app that works for all users.

Why Accessibility Matters

RegulationScopeKey Requirement
ADA (Americans with Disabilities Act)US — public accommodations, including appsEquivalent access for users with disabilities
WCAG 2.1 AAInternational standard, referenced by most lawsPerceivable, Operable, Understandable, Robust
European Accessibility Act (2025)EU — products and services sold after June 2025Digital services must meet EN 301 549 (maps to WCAG 2.1 AA)
Section 508US federal agenciesWCAG 2.0 AA conformance for government software

Non-compliance exposes organizations to lawsuits, fines, and app store rejections. Apple and Google both review accessibility during app review and can reject apps that fail basic checks.

Business Case

The World Health Organization estimates that 15% of the global population — over one billion people — lives with some form of disability. Beyond permanent disabilities, situational impairments (bright sunlight, one hand occupied, noisy environment) affect everyone. Accessible apps convert more users and retain them longer.

Flutter's Structural Advantage

Flutter renders its own pixels through Skia/Impeller, but it also maintains a parallel Semantics tree that is exposed to platform accessibility services. This means one set of Semantics annotations covers both VoiceOver (iOS) and TalkBack (Android) — unlike native development where you annotate UIKit and Android Views separately.

The Semantics Tree

How It Works

Widget tree

Element tree (diff engine)

RenderObject tree (layout + paint)

Semantics tree (accessibility metadata)

Platform a11y API
   ├── UIAccessibility (iOS)
   └── AccessibilityNodeInfo (Android)

Every frame, Flutter walks the RenderObject tree and assembles a parallel tree of SemanticsNode objects. The engine serializes these nodes into platform-specific accessibility structures. Screen readers, switch devices, and braille displays all consume these structures.

Implicit Semantics

Many built-in widgets automatically contribute semantics without any annotation:

WidgetImplicit Semantics
TextExposes its string as a label
ElevatedButton / TextButtonMarked as a button with the child text as its label
TextFieldExposes value, hint, editing state
Checkbox / SwitchExposes checked/unchecked state
ImageExposes semanticLabel if provided
SliderExposes value, increase/decrease actions
AppBarTitle exposed as a header

This implicit behavior means that apps built with standard Material/Cupertino widgets start with a reasonable accessibility baseline.

Explicit Semantics

For custom widgets that draw their own content, you must add semantics manually:

// Custom status indicator that draws a colored circle
class StatusIndicator extends StatelessWidget {
  final bool isOnline;
  const StatusIndicator({super.key, required this.isOnline});

  @override
  Widget build(BuildContext context) {
    return Semantics(
      label: isOnline ? 'Online' : 'Offline',
      // Prevent children from contributing their own semantics
      excludeSemantics: true,
      child: Container(
        width: 12,
        height: 12,
        decoration: BoxDecoration(
          shape: BoxShape.circle,
          color: isOnline ? Colors.green : Colors.grey,
        ),
      ),
    );
  }
}

SemanticsProperties Reference

The Semantics widget accepts a wide set of properties:

PropertyTypePurpose
labelStringPrimary description read by screen reader
hintStringDescribes what happens on activation ("Double tap to open")
valueStringCurrent value (slider position, text field content)
readOnlyboolMarks the element as non-editable
enabledboolWhether the control accepts interaction
buttonboolMarks as a button role
headerboolMarks as a section header
linkboolMarks as a hyperlink
imageboolMarks as an image
liveRegionboolContent changes are announced automatically
onTapVoidCallbackCustom tap handler exposed to assistive tech
onLongPressVoidCallbackCustom long-press handler
increasedValue / decreasedValueStringValues after increase/decrease actions

MergeSemantics and ExcludeSemantics

MergeSemantics collapses multiple semantic nodes into one, which simplifies the reading experience for screen reader users:

// Without merge: screen reader reads "Price", then "$42.99" as separate items
// With merge: reads "Price $42.99" as one unit
MergeSemantics(
  child: Row(
    children: [
      Text('Price'),
      const SizedBox(width: 8),
      Text('\$42.99'),
    ],
  ),
)

ExcludeSemantics removes a subtree from the accessibility tree entirely. Use it only for purely decorative elements:

// Decorative background pattern — not useful to screen reader users
ExcludeSemantics(
  child: CustomPaint(painter: BackgroundPatternPainter()),
)

Never use ExcludeSemantics on interactive elements. Wrapping a button or input in ExcludeSemantics makes it invisible to assistive technology. If a sighted user can tap it, a screen reader user must be able to reach it.

Debugging the Semantics Tree

Dump the full semantics tree from the command line:

flutter run --dump-semantics-tree-in-traversal-order

Or enable the Semantics Debugger overlay in code:

MaterialApp(
  showSemanticsDebugger: true,
  // ...
)

This replaces the visual rendering with a text overlay showing what screen readers see — an immediate way to spot missing labels or wrong groupings.

Accessible Custom Widget Example

A complete example of a custom rating widget with proper semantics:

class StarRating extends StatelessWidget {
  final int rating;
  final int maxRating;
  final ValueChanged<int>? onChanged;

  const StarRating({
    super.key,
    required this.rating,
    this.maxRating = 5,
    this.onChanged,
  });

  @override
  Widget build(BuildContext context) {
    return Semantics(
      label: 'Rating',
      value: '$rating out of $maxRating stars',
      hint: onChanged != null ? 'Double tap to change rating' : null,
      enabled: onChanged != null,
      increasedValue: rating < maxRating
          ? '${rating + 1} out of $maxRating stars'
          : null,
      decreasedValue: rating > 0
          ? '${rating - 1} out of $maxRating stars'
          : null,
      onIncrease: onChanged != null && rating < maxRating
          ? () => onChanged!(rating + 1)
          : null,
      onDecrease: onChanged != null && rating > 0
          ? () => onChanged!(rating - 1)
          : null,
      child: ExcludeSemantics(
        child: Row(
          mainAxisSize: MainAxisSize.min,
          children: List.generate(maxRating, (i) {
            return GestureDetector(
              onTap: onChanged != null ? () => onChanged!(i + 1) : null,
              child: Icon(
                i < rating ? Icons.star : Icons.star_border,
                color: Colors.amber,
                size: 32,
              ),
            );
          }),
        ),
      ),
    );
  }
}

The key pattern: wrap the entire custom widget in a single Semantics node with meaningful value, increasedValue, and decreasedValue, then wrap the visual children in ExcludeSemantics so screen readers see one coherent control rather than five individual star icons.

Screen Readers

VoiceOver and TalkBack Behavior Differences

BehaviorVoiceOver (iOS)TalkBack (Android)
Activation gestureDouble tapDouble tap
ScrollingThree-finger swipeTwo-finger swipe
Element navigationSwipe left/rightSwipe left/right
Custom actionsRotor (two-finger rotate)Local context menu
Heading navigationRotor → HeadingsReading controls → Headings
Announcement verbosityGenerally terserMore verbose by default

Always test on both platforms. A label that sounds natural on VoiceOver may be redundant on TalkBack or vice versa.

Reading Order

Screen readers traverse the Semantics tree in the order nodes appear. By default, this follows the widget tree order, which maps to visual reading order (top-to-bottom, start-to-end). Problems arise when visual layout diverges from tree order — for example, a Stack where the visually top element is last in the children list.

Overriding Traversal Order with OrdinalSortKey

// Force the "Skip" button to be read after the "Submit" button
// even though "Skip" appears first in the widget tree
Column(
  children: [
    Semantics(
      sortKey: const OrdinalSortKey(1),
      child: TextButton(onPressed: skip, child: const Text('Skip')),
    ),
    Semantics(
      sortKey: const OrdinalSortKey(0),
      child: ElevatedButton(onPressed: submit, child: const Text('Submit')),
    ),
  ],
)

Use OrdinalSortKey sparingly. If you need many sort overrides, restructure the widget tree to match the intended reading order instead.

Common Screen Reader Issues

  1. Decorative images read aloud: An Image without a semanticLabel may still expose the asset path. Always set semanticLabel or wrap in ExcludeSemantics.
  2. Missing button labels: An IconButton with no tooltip produces "button" with no description. Always set tooltip on icon-only buttons.
  3. Vague announcements: "Button, double tap to activate" with no context. Provide a descriptive label.
  4. Redundant information: A card that reads "Order 12345, Order number 12345, Status shipped, Order status shipped" — merge semantics or restructure.

Live Announcements with SemanticsService

Use SemanticsService.announce() to push transient announcements — loading states, errors, success confirmations — to screen readers without changing focus:

import 'package:flutter/semantics.dart';

Future<void> submitOrder(BuildContext context) async {
  SemanticsService.announce('Submitting order', TextDirection.ltr);
  try {
    await api.submitOrder();
    SemanticsService.announce('Order submitted successfully', TextDirection.ltr);
  } catch (e) {
    SemanticsService.announce('Order submission failed. Please try again.', TextDirection.ltr);
  }
}

Announce sparingly. Over-announcing interrupts the user's current reading position. Reserve announcements for state changes the user is waiting for: async operation results, error conditions, and live data updates.

Custom Slider with Proper Semantics

class AccessibleCustomSlider extends StatefulWidget {
  final double value;
  final double min;
  final double max;
  final String label;
  final ValueChanged<double> onChanged;

  const AccessibleCustomSlider({
    super.key,
    required this.value,
    this.min = 0,
    this.max = 100,
    required this.label,
    required this.onChanged,
  });

  @override
  State<AccessibleCustomSlider> createState() => _AccessibleCustomSliderState();
}

class _AccessibleCustomSliderState extends State<AccessibleCustomSlider> {
  double _currentValue = 0;

  @override
  void initState() {
    super.initState();
    _currentValue = widget.value;
  }

  @override
  void didUpdateWidget(AccessibleCustomSlider oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.value != widget.value) _currentValue = widget.value;
  }

  void _increment() {
    final step = (widget.max - widget.min) / 10;
    final newVal = (_currentValue + step).clamp(widget.min, widget.max);
    setState(() => _currentValue = newVal);
    widget.onChanged(newVal);
  }

  void _decrement() {
    final step = (widget.max - widget.min) / 10;
    final newVal = (_currentValue - step).clamp(widget.min, widget.max);
    setState(() => _currentValue = newVal);
    widget.onChanged(newVal);
  }

  @override
  Widget build(BuildContext context) {
    final percentage = ((_currentValue - widget.min) /
            (widget.max - widget.min) * 100)
        .round();

    return Semantics(
      label: widget.label,
      value: '$percentage percent',
      increasedValue: '${(percentage + 10).clamp(0, 100)} percent',
      decreasedValue: '${(percentage - 10).clamp(0, 100)} percent',
      onIncrease: _increment,
      onDecrease: _decrement,
      slider: true,
      child: GestureDetector(
        onHorizontalDragUpdate: (details) {
          final box = context.findRenderObject() as RenderBox;
          final fraction = (details.localPosition.dx / box.size.width)
              .clamp(0.0, 1.0);
          final newVal =
              widget.min + fraction * (widget.max - widget.min);
          setState(() => _currentValue = newVal);
          widget.onChanged(newVal);
        },
        child: CustomPaint(
          size: const Size(double.infinity, 48),
          painter: _SliderPainter(
            fraction: (_currentValue - widget.min) /
                (widget.max - widget.min),
            activeColor: Theme.of(context).colorScheme.primary,
            trackColor: Theme.of(context).colorScheme.surfaceContainerHighest,
          ),
        ),
      ),
    );
  }
}

class _SliderPainter extends CustomPainter {
  final double fraction;
  final Color activeColor;
  final Color trackColor;

  _SliderPainter({
    required this.fraction,
    required this.activeColor,
    required this.trackColor,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final trackY = size.height / 2;
    final trackPaint = Paint()..color = trackColor;
    final activePaint = Paint()..color = activeColor;
    canvas.drawRRect(
      RRect.fromRectAndRadius(
        Rect.fromLTWH(0, trackY - 2, size.width, 4),
        const Radius.circular(2),
      ),
      trackPaint,
    );
    canvas.drawRRect(
      RRect.fromRectAndRadius(
        Rect.fromLTWH(0, trackY - 2, size.width * fraction, 4),
        const Radius.circular(2),
      ),
      activePaint,
    );
    canvas.drawCircle(
      Offset(size.width * fraction, trackY),
      12,
      activePaint,
    );
  }

  @override
  bool shouldRepaint(_SliderPainter old) =>
      fraction != old.fraction ||
      activeColor != old.activeColor ||
      trackColor != old.trackColor;
}

Focus Management

FocusNode and FocusScope

Every focusable widget in Flutter is backed by a FocusNode. FocusScope groups nodes and controls which scope currently holds primary focus:

class SearchPage extends StatefulWidget {
  const SearchPage({super.key});
  @override
  State<SearchPage> createState() => _SearchPageState();
}

class _SearchPageState extends State<SearchPage> {
  final _searchFocus = FocusNode();

  @override
  void dispose() {
    _searchFocus.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Search')),
      body: Column(
        children: [
          TextField(
            focusNode: _searchFocus,
            autofocus: true, // grab focus when this screen appears
            decoration: const InputDecoration(hintText: 'Search...'),
          ),
          // ...results
        ],
      ),
    );
  }
}

FocusTraversalGroup and Policies

FocusTraversalGroup defines a scope for keyboard (and switch control) tab traversal:

FocusTraversalGroup(
  policy: OrderedTraversalPolicy(),
  child: Column(
    children: [
      FocusTraversalOrder(
        order: const NumericFocusOrder(2),
        child: TextField(decoration: const InputDecoration(labelText: 'Email')),
      ),
      FocusTraversalOrder(
        order: const NumericFocusOrder(1),
        child: TextField(decoration: const InputDecoration(labelText: 'Name')),
      ),
      FocusTraversalOrder(
        order: const NumericFocusOrder(3),
        child: ElevatedButton(
          onPressed: () {},
          child: const Text('Submit'),
        ),
      ),
    ],
  ),
)
PolicyBehavior
ReadingOrderTraversalPolicyDefault — follows visual reading order (LTR/RTL aware)
OrderedTraversalPolicyRespects explicit FocusTraversalOrder annotations
WidgetOrderTraversalPolicyFollows widget tree order regardless of position

Focus After Navigation

When pushing a new route, set autofocus: true on the primary content element, or manually request focus after the route transition completes:

Navigator.push(context, MaterialPageRoute(
  builder: (_) => DetailPage(),
)).then((_) {
  // Return focus to a specific element after popping back
  _listFocusNode.requestFocus();
});

Accessible Form with Focus Management

class AccessibleForm extends StatefulWidget {
  const AccessibleForm({super.key});
  @override
  State<AccessibleForm> createState() => _AccessibleFormState();
}

class _AccessibleFormState extends State<AccessibleForm> {
  final _formKey = GlobalKey<FormState>();
  final _nameFocus = FocusNode();
  final _emailFocus = FocusNode();
  final _passwordFocus = FocusNode();
  final _nameController = TextEditingController();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();

  @override
  void dispose() {
    _nameFocus.dispose();
    _emailFocus.dispose();
    _passwordFocus.dispose();
    _nameController.dispose();
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

  void _submit() {
    if (!_formKey.currentState!.validate()) {
      // Find the first field with an error and move focus to it
      if (_nameController.text.isEmpty) {
        _nameFocus.requestFocus();
        SemanticsService.announce(
          'Name is required',
          TextDirection.ltr,
        );
      } else if (!_emailController.text.contains('@')) {
        _emailFocus.requestFocus();
        SemanticsService.announce(
          'Please enter a valid email address',
          TextDirection.ltr,
        );
      } else {
        _passwordFocus.requestFocus();
        SemanticsService.announce(
          'Password must be at least 8 characters',
          TextDirection.ltr,
        );
      }
      return;
    }
    // Submit the form
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: FocusTraversalGroup(
        policy: OrderedTraversalPolicy(),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            FocusTraversalOrder(
              order: const NumericFocusOrder(0),
              child: TextFormField(
                controller: _nameController,
                focusNode: _nameFocus,
                autofocus: true,
                decoration: const InputDecoration(labelText: 'Full name'),
                textInputAction: TextInputAction.next,
                onFieldSubmitted: (_) => _emailFocus.requestFocus(),
                validator: (v) =>
                    v == null || v.isEmpty ? 'Name is required' : null,
              ),
            ),
            const SizedBox(height: 16),
            FocusTraversalOrder(
              order: const NumericFocusOrder(1),
              child: TextFormField(
                controller: _emailController,
                focusNode: _emailFocus,
                decoration: const InputDecoration(labelText: 'Email address'),
                keyboardType: TextInputType.emailAddress,
                textInputAction: TextInputAction.next,
                onFieldSubmitted: (_) => _passwordFocus.requestFocus(),
                validator: (v) =>
                    v != null && v.contains('@') ? null : 'Enter a valid email',
              ),
            ),
            const SizedBox(height: 16),
            FocusTraversalOrder(
              order: const NumericFocusOrder(2),
              child: TextFormField(
                controller: _passwordController,
                focusNode: _passwordFocus,
                decoration: const InputDecoration(labelText: 'Password'),
                obscureText: true,
                textInputAction: TextInputAction.done,
                onFieldSubmitted: (_) => _submit(),
                validator: (v) => v != null && v.length >= 8
                    ? null
                    : 'At least 8 characters required',
              ),
            ),
            const SizedBox(height: 24),
            FocusTraversalOrder(
              order: const NumericFocusOrder(3),
              child: ElevatedButton(
                onPressed: _submit,
                child: const Text('Create account'),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Key patterns: move focus to the first validation error, announce errors for screen reader users, chain field submission with onFieldSubmitted so keyboard users can tab naturally through the form.

Dynamic Type and Text Scaling

Reading the Text Scale Factor

Flutter 3.16+ replaced textScaleFactor (a single double) with TextScaler, which supports non-linear scaling:

// Flutter 3.16+
final scaler = MediaQuery.textScalerOf(context);
final scaledSize = scaler.scale(16.0); // base 16 → scaled value

// Pre-3.16 (deprecated)
// final factor = MediaQuery.textScaleFactorOf(context);

Testing at Multiple Scale Factors

Wrap your app or a subtree in a MediaQuery override to simulate different settings:

// In a widget test or debug build
MediaQuery(
  data: MediaQuery.of(context).copyWith(
    textScaler: const TextScaler.linear(2.0),
  ),
  child: const MyWidget(),
)

Test at 1.0x (default), 1.5x, and 2.0x as a minimum. Some users set even higher values.

Layout Survival at 2x Scale

The primary failure mode is text overflowing fixed-height containers. Avoid hardcoded heights for anything containing text:

// BROKEN at 2x text scale: text overflows the 48px box
SizedBox(
  height: 48,
  child: Text('This text will overflow at large scale factors'),
)

// CORRECT: let the container grow with its content
ConstrainedBox(
  constraints: const BoxConstraints(minHeight: 48),
  child: Padding(
    padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
    child: Text('This text wraps and the container expands'),
  ),
)

A more complete example of a card that handles text scaling gracefully:

class ScalableInfoCard extends StatelessWidget {
  final String title;
  final String subtitle;
  final IconData icon;

  const ScalableInfoCard({
    super.key,
    required this.title,
    required this.subtitle,
    required this.icon,
  });

  @override
  Widget build(BuildContext context) {
    // Switch to vertical layout when text scaling is aggressive
    final scaler = MediaQuery.textScalerOf(context);
    final isLargeText = scaler.scale(1.0) > 1.3;

    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: isLargeText
            ? Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Icon(icon, size: 32),
                  const SizedBox(height: 8),
                  Text(title, style: Theme.of(context).textTheme.titleMedium),
                  const SizedBox(height: 4),
                  Text(subtitle, style: Theme.of(context).textTheme.bodyMedium),
                ],
              )
            : Row(
                children: [
                  Icon(icon, size: 32),
                  const SizedBox(width: 16),
                  Expanded(
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text(title,
                            style: Theme.of(context).textTheme.titleMedium),
                        Text(subtitle,
                            style: Theme.of(context).textTheme.bodyMedium),
                      ],
                    ),
                  ),
                ],
              ),
      ),
    );
  }
}

Never clamp textScaler to 1.0. Some apps force MediaQuery(data: data.copyWith(textScaler: TextScaler.noScaling), ...) at the root to "prevent layout issues." This disables a core accessibility feature. Fix your layouts instead.

Color and Contrast

WCAG Contrast Ratios

ElementMinimum RatioExample
Normal text (< 18pt / < 14pt bold)4.5:1Body copy, labels
Large text (>= 18pt / >= 14pt bold)3:1Headings, large buttons
UI components and graphical objects3:1Icons, input borders, focus rings

Dark Mode Is Not Just Inversion

Simply swapping black and white does not guarantee contrast compliance. Common failures:

  • Grey text on dark backgrounds dropping below 4.5:1
  • Colored buttons (brand colors) that pass on light but fail on dark
  • Disabled states that become invisible

Always verify contrast ratios in both themes.

Semantic Color Usage

Never use color as the sole carrier of information:

// BAD: relies on color alone to indicate error
TextField(
  decoration: InputDecoration(
    // A color-blind user cannot distinguish this from a normal field
    enabledBorder: OutlineInputBorder(
      borderSide: BorderSide(color: hasError ? Colors.red : Colors.grey),
    ),
  ),
)

// GOOD: color + icon + text
TextField(
  decoration: InputDecoration(
    errorText: hasError ? 'This field is required' : null,
    suffixIcon: hasError
        ? const Icon(Icons.error_outline, semanticLabel: 'Error')
        : null,
    enabledBorder: OutlineInputBorder(
      borderSide: BorderSide(color: hasError ? Colors.red : Colors.grey),
    ),
  ),
)

Contrast-Safe Theme with ThemeExtension

class ContrastSafeColors extends ThemeExtension<ContrastSafeColors> {
  /// Error: red with sufficient contrast on both light and dark backgrounds
  final Color error;
  /// Success: not pure green (fails for deuteranopia) — use teal-shifted green
  final Color success;
  /// Warning: dark amber, not yellow (yellow fails contrast on white)
  final Color warning;
  /// Info: blue, universally distinguishable
  final Color info;

  const ContrastSafeColors({
    required this.error,
    required this.success,
    required this.warning,
    required this.info,
  });

  // Light theme: verified against white (#FFFFFF)
  static const light = ContrastSafeColors(
    error: Color(0xFFB3261E),    // 5.9:1 on white
    success: Color(0xFF1B6D3D),  // 5.4:1 on white
    warning: Color(0xFF7C4D12),  // 5.1:1 on white
    info: Color(0xFF1A56DB),     // 5.7:1 on white
  );

  // Dark theme: verified against dark surface (#1C1B1F)
  static const dark = ContrastSafeColors(
    error: Color(0xFFF2B8B5),    // 7.3:1 on dark surface
    success: Color(0xFF7DD9A0),  // 6.8:1 on dark surface
    warning: Color(0xFFE8C871),  // 7.1:1 on dark surface
    info: Color(0xFF93B4F5),     // 6.2:1 on dark surface
  );

  @override
  ContrastSafeColors copyWith({
    Color? error,
    Color? success,
    Color? warning,
    Color? info,
  }) =>
      ContrastSafeColors(
        error: error ?? this.error,
        success: success ?? this.success,
        warning: warning ?? this.warning,
        info: info ?? this.info,
      );

  @override
  ContrastSafeColors lerp(ThemeExtension<ContrastSafeColors>? other, double t) {
    if (other is! ContrastSafeColors) return this;
    return ContrastSafeColors(
      error: Color.lerp(error, other.error, t)!,
      success: Color.lerp(success, other.success, t)!,
      warning: Color.lerp(warning, other.warning, t)!,
      info: Color.lerp(info, other.info, t)!,
    );
  }
}

Error State with Multiple Cues

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

  @override
  Widget build(BuildContext context) {
    final colors = Theme.of(context).extension<ContrastSafeColors>()!;
    return Semantics(
      liveRegion: true, // announce when this appears
      child: Container(
        padding: const EdgeInsets.all(12),
        decoration: BoxDecoration(
          color: colors.error.withOpacity(0.1),
          border: Border.all(color: colors.error),
          borderRadius: BorderRadius.circular(8),
        ),
        child: Row(
          children: [
            Icon(Icons.error_outline, color: colors.error, semanticLabel: ''),
            const SizedBox(width: 12),
            Expanded(
              child: Text(
                message,
                style: TextStyle(color: colors.error),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Three channels convey the error: color (red border and background), iconography (error icon), and text (the error message). A user who cannot perceive any one of these channels can still understand the state from the other two.

Motion and Vestibular Sensitivity

Honoring Reduce-Motion Preferences

Both iOS and Android expose a "reduce motion" accessibility setting. Flutter surfaces this through MediaQuery:

@override
Widget build(BuildContext context) {
  final reduceMotion = MediaQuery.disableAnimationsOf(context);

  return AnimatedContainer(
    duration: reduceMotion ? Duration.zero : const Duration(milliseconds: 300),
    curve: Curves.easeInOut,
    height: isExpanded ? 200 : 0,
    child: content,
  );
}

Conditional Animation Pattern

For complex page transitions, parallax effects, or auto-scrolling carousels:

class MotionAwarePageRoute<T> extends MaterialPageRoute<T> {
  MotionAwarePageRoute({required super.builder});

  @override
  Duration get transitionDuration {
    // Check the navigator context for reduce-motion
    // Fall back to instant transition when motion is reduced
    return const Duration(milliseconds: 300);
  }

  @override
  Widget buildTransitions(
    BuildContext context,
    Animation<double> animation,
    Animation<double> secondaryAnimation,
    Widget child,
  ) {
    if (MediaQuery.disableAnimationsOf(context)) {
      // No transition — instant cut
      return child;
    }
    return SlideTransition(
      position: animation.drive(
        Tween(begin: const Offset(1, 0), end: Offset.zero)
            .chain(CurveTween(curve: Curves.easeOut)),
      ),
      child: child,
    );
  }
}

Auto-playing carousels and parallax scrolling are the most common vestibular triggers. If your app includes these, gate them behind the reduce-motion check and provide a static fallback. A carousel can display a single hero image; parallax can flatten to normal scrolling.

Touch Target Size

Minimum Sizes

PlatformMinimum Target SizeSource
Android (Material)48 x 48 dpMaterial Design guidelines
iOS (Human Interface)44 x 44 ptApple HIG
WCAG 2.5.5 (AAA)44 x 44 CSS pxWCAG 2.1

MaterialTapTargetSize

Flutter's Material widgets have a built-in minimum tap target via MaterialTapTargetSize:

// Default: adds padding to reach 48x48
IconButton(
  icon: const Icon(Icons.close),
  onPressed: () {},
  // materialTapTargetSize: MaterialTapTargetSize.padded (default)
)

// Opt out (use only when targets are in a dense, keyboard-accessible toolbar)
IconButton(
  icon: const Icon(Icons.close),
  onPressed: () {},
  materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
)

Ensuring Minimum Targets on Custom Widgets

When building custom interactive elements that do not extend Material widgets:

class AccessibleTapTarget extends StatelessWidget {
  final Widget child;
  final VoidCallback onTap;
  final String semanticLabel;

  const AccessibleTapTarget({
    super.key,
    required this.child,
    required this.onTap,
    required this.semanticLabel,
  });

  @override
  Widget build(BuildContext context) {
    return Semantics(
      button: true,
      label: semanticLabel,
      child: GestureDetector(
        onTap: onTap,
        child: ConstrainedBox(
          constraints: const BoxConstraints(
            minWidth: 48,
            minHeight: 48,
          ),
          child: Center(child: child),
        ),
      ),
    );
  }
}

Small targets are the most common accessibility failure in mobile apps. The WCAG 2.5.8 criterion (new in WCAG 2.2) requires a 24x24 CSS px minimum at AA level. Material's 48dp default exceeds this, but custom widgets and shrinkWrap overrides frequently violate it. Run the Android Accessibility Scanner to catch undersized targets.

Testing Accessibility

Platform Accessibility Tools

ToolPlatformWhat It Tests
Accessibility InspectoriOS SimulatorSemantics tree, VoiceOver simulation
Accessibility ScannerAndroidTouch target size, contrast, labels
VoiceOver (manual)iOS deviceReal screen reader experience
TalkBack (manual)Android deviceReal screen reader experience

Flutter Semantics Debugger

Enable the overlay to see what the accessibility tree looks like:

MaterialApp(
  showSemanticsDebugger: true,
  home: MyHomePage(),
)

Widget Test Semantics Matchers

Flutter's test framework provides matchesSemantics for verifying accessibility properties in automated tests:

testWidgets('star rating has correct semantics', (tester) async {
  await tester.pumpWidget(
    const MaterialApp(
      home: Scaffold(
        body: StarRating(rating: 3, maxRating: 5),
      ),
    ),
  );

  expect(
    tester.getSemantics(find.byType(StarRating)),
    matchesSemantics(
      label: 'Rating',
      value: '3 out of 5 stars',
      increasedValue: '4 out of 5 stars',
      decreasedValue: '2 out of 5 stars',
      hasIncreaseAction: true,
      hasDecreaseAction: true,
    ),
  );
});

testWidgets('error banner is a live region', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      theme: ThemeData(
        extensions: const [ContrastSafeColors.light],
      ),
      home: const Scaffold(
        body: AccessibleErrorBanner(message: 'Network error'),
      ),
    ),
  );

  final semantics = tester.getSemantics(
    find.byType(AccessibleErrorBanner),
  );
  expect(semantics, matchesSemantics(
    isLiveRegion: true,
    hasImplicitScrolling: false,
  ));
});

Automated CI Checks

Create a custom test helper that enforces accessibility rules across all screens:

/// Verify that no interactive semantic node has an empty label
void assertNoEmptyLabels(WidgetTester tester) {
  final semanticsOwner = tester.binding.pipelineOwner.semanticsOwner;
  if (semanticsOwner == null) return;

  void visit(SemanticsNode node) {
    final data = node.getSemanticsData();
    final isInteractive = data.hasAction(SemanticsAction.tap) ||
        data.hasAction(SemanticsAction.longPress) ||
        data.hasFlag(SemanticsFlag.isButton);

    if (isInteractive) {
      expect(
        data.label.isNotEmpty || data.value.isNotEmpty,
        isTrue,
        reason: 'Interactive node at ${node.rect} has no label or value. '
            'Add a semanticLabel, tooltip, or Semantics widget.',
      );
    }

    node.visitChildren(visit);
  }

  visit(semanticsOwner.rootSemanticsNode!);
}

// Usage in tests
testWidgets('home page has no empty labels', (tester) async {
  await tester.pumpWidget(const MyApp());
  await tester.pumpAndSettle();
  assertNoEmptyLabels(tester);
});

Manual Testing Protocol

No automated tool catches everything. Run this protocol before every release:

  1. VoiceOver walkthrough (iOS): Navigate every screen with swipe gestures. Every interactive element must announce its purpose.
  2. TalkBack walkthrough (Android): Same as above. Check that reading order matches visual layout.
  3. Keyboard navigation (connected keyboard or desktop): Tab through all interactive elements. Verify visible focus ring.
  4. Large text (Settings > Accessibility > Larger Text): Set to maximum. Verify no text truncation without ellipsis, no overlapping elements.
  5. Bold text (iOS): Enable bold text. Verify layout integrity.
  6. Reduce motion: Enable reduce motion. Verify animations are suppressed or simplified.
  7. Color inversion / high contrast: Verify readability.
  8. Switch control: Navigate with switch control on iOS or Switch Access on Android. Verify all interactive elements are reachable.

Anti-Patterns

Empty Semantics Labels

// WRONG: worse than no label — screen reader reads "button" with no context
Semantics(
  label: '',
  button: true,
  child: myIcon,
)

// RIGHT: descriptive label
Semantics(
  label: 'Close dialog',
  button: true,
  child: myIcon,
)

ExcludeSemantics on Interactive Elements

// WRONG: makes the button invisible to assistive technology
ExcludeSemantics(
  child: IconButton(
    icon: const Icon(Icons.delete),
    onPressed: deleteItem,
  ),
)

// RIGHT: if the button's default semantics are insufficient, override them
Semantics(
  label: 'Delete order',
  button: true,
  child: IconButton(
    icon: const Icon(Icons.delete),
    onPressed: deleteItem,
    tooltip: 'Delete order',
  ),
)

Images Without semanticLabel

// WRONG: screen reader may announce the file path
Image.asset('assets/profile.png')

// RIGHT: meaningful description for content images
Image.asset(
  'assets/profile.png',
  semanticLabel: 'User profile photo',
)

// RIGHT: purely decorative image excluded from semantics
ExcludeSemantics(
  child: Image.asset('assets/decorative_wave.png'),
)

Custom Gesture Detectors Without Semantics

// WRONG: invisible to screen readers
GestureDetector(
  onTap: () => toggleFavorite(),
  onLongPress: () => showOptions(),
  child: const Icon(Icons.favorite_border),
)

// RIGHT: semantics expose both actions
Semantics(
  label: 'Favorite',
  hint: 'Double tap to toggle, long press for options',
  button: true,
  onTap: () => toggleFavorite(),
  onLongPress: () => showOptions(),
  child: GestureDetector(
    onTap: () => toggleFavorite(),
    onLongPress: () => showOptions(),
    child: const Icon(Icons.favorite_border),
  ),
)

Infinite Scroll Without Announcements

// WRONG: new content appears silently — screen reader user does not know
NotificationListener<ScrollNotification>(
  onNotification: (notification) {
    if (notification.metrics.pixels >=
        notification.metrics.maxScrollExtent - 200) {
      loadMoreItems();
    }
    return false;
  },
  child: ListView.builder(...),
)

// RIGHT: announce when new content loads
void loadMoreItems() async {
  final newItems = await api.fetchMore();
  setState(() => items.addAll(newItems));
  SemanticsService.announce(
    '${newItems.length} more items loaded',
    TextDirection.ltr,
  );
}

Dialogs That Do Not Trap Focus

Flutter's showDialog automatically traps focus within the dialog via ModalBarrier. If you build a custom overlay instead of using showDialog, you must manage focus trapping yourself:

// WRONG: custom overlay with no focus trap — keyboard/screen reader can
// escape behind it
Overlay.of(context).insert(OverlayEntry(
  builder: (_) => MyCustomDialog(),
));

// RIGHT: use showDialog or showModalBottomSheet, which handle focus
// trapping and barrier semantics automatically
showDialog(
  context: context,
  builder: (_) => AlertDialog(
    title: const Text('Confirm deletion'),
    content: const Text('This action cannot be undone.'),
    actions: [
      TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
      TextButton(onPressed: () { delete(); Navigator.pop(context); }, child: const Text('Delete')),
    ],
  ),
);

Accessibility Checklist

Run through this table before every release:

CategoryCheckHow to Verify
SemanticsAll interactive elements have descriptive labelsshowSemanticsDebugger: true, VoiceOver/TalkBack walkthrough
SemanticsNo empty label: '' on interactive nodesCustom lint or CI test with assertNoEmptyLabels
SemanticsDecorative images excluded, content images labeledCode review, Semantics Debugger
SemanticsCustom widgets expose roles (button, slider, header)matchesSemantics tests
FocusDialogs and new screens receive focus automaticallyKeyboard navigation test
FocusFocus moves to first error on form validation failureManual test with screen reader
FocusTab order matches visual reading orderKeyboard walkthrough
Text ScaleLayout survives 2x text scale without truncation or overflowMediaQuery override in tests, device settings
Text ScaleNo hardcoded heights on text containersCode review
ContrastText meets 4.5:1 (normal) or 3:1 (large) against backgroundAccessibility Scanner, manual check with contrast tool
ContrastUI components (icons, borders) meet 3:1 against adjacent colorsManual check
ContrastBoth light and dark themes verifiedTest both themes
ColorInformation not conveyed by color aloneCheck error/success/warning states
MotionAnimations respect disableAnimationsOf(context)Enable reduce motion in device settings
MotionNo auto-playing animations without user controlManual review
TouchAll interactive targets are at least 48x48 dpAndroid Accessibility Scanner
TouchCustom widgets use ConstrainedBox or equivalent for minimum sizeCode review
Screen ReaderVoiceOver walkthrough of all screens completedManual test on iOS device
Screen ReaderTalkBack walkthrough of all screens completedManual test on Android device
Live RegionsAsync state changes (loading, error, success) are announcedScreen reader test during network operations

Accessibility is not a one-time effort. Every feature addition can introduce regressions. Include the checklist items above in your pull request template, add matchesSemantics tests for every new custom widget, and schedule periodic manual screen reader walkthroughs. The cost of maintaining accessibility is low when it is part of the development cycle from the start; retrofitting it later is expensive and error-prone.

On this page