Skip to content

Semantics Accessibility issues with aria-label #192618

Open

Description

@Mickelle

Steps to reproduce

  1. Run the sample app on chrome browser
  2. Open the browser inspector and observe the accessibility

Expected results

  1. Text input has aria-label
  2. Submit button has an aria-label
  3. Dropdown2 with Semantics wrapper has an aria-label

Actual results

  1. Text input has aria-label
  2. Submit button does not have an aria-label. It does have a computed name but to meet accessibility requirements, client needs aria-label.
    Wrapping the button in semantics (see Cancel button) applies an aria-label but we see a new child node
Image
  1. Dropdown2 with Semantics wrapper has no aria-label
Image

Code sample

Code sample
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:dropdown_button2/dropdown_button2.dart';

void main() {
  runApp(const MyApp());
  SemanticsBinding.instance.ensureSemantics();
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Sample Form',
      theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), useMaterial3: true),
      home: const SampleFormPage(),
    );
  }
}

class SampleFormPage extends StatefulWidget {
  const SampleFormPage({super.key});

  @override
  State<SampleFormPage> createState() => _SampleFormPageState();
}

class _SampleFormPageState extends State<SampleFormPage> {
  final _formKey = GlobalKey<FormState>();

  final _firstNameController = TextEditingController();
  final _lastNameController = TextEditingController();
  final _emailController = TextEditingController();

  String? _selectedCountry;
  String? _selectedDepartment;
  final ValueNotifier<String?> _departmentValue = ValueNotifier<String?>(null);

  final List<String> _countries = ['India', 'United States', 'United Kingdom', 'Canada', 'Australia'];

  final List<String> _departments = ['Engineering', 'Design', 'Product', 'Marketing', 'Sales'];

  final Map<String, bool> _interests = {'Technology': false, 'Sports': false, 'Music': false, 'Travel': false};

  String? _experience;

  @override
  void dispose() {
    _firstNameController.dispose();
    _lastNameController.dispose();
    _emailController.dispose();
    _departmentValue.dispose();
    super.dispose();
  }

  void _submitForm() {
    if (!_formKey.currentState!.validate()) {
      return;
    }

    final selectedInterests = _interests.entries.where((entry) => entry.value).map((entry) => entry.key).toList();

    debugPrint('First Name: ${_firstNameController.text}');
    debugPrint('Last Name: ${_lastNameController.text}');
    debugPrint('Email: ${_emailController.text}');
    debugPrint('Country: $_selectedCountry');
    debugPrint('Department: $_selectedDepartment');
    debugPrint('Interests: $selectedInterests');
    debugPrint('Experience: $_experience');

    ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Form submitted successfully')));
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Sample Flutter Form')),
      body: Center(
        child: ConstrainedBox(
          constraints: const BoxConstraints(maxWidth: 700),
          child: SingleChildScrollView(
            padding: const EdgeInsets.all(24),
            child: Form(
              key: _formKey,
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  const Text('Registration Form', style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)),

                  const SizedBox(height: 8),

                  const Text('Please fill in the information below.'),

                  const SizedBox(height: 32),

                  // -------------------------------------------------------
                  // TEXT INPUTS
                  // -------------------------------------------------------
                  const Text('Personal Information', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),

                  const SizedBox(height: 16),

                  TextFormField(
                    controller: _firstNameController,
                    decoration: const InputDecoration(
                      labelText: 'First name',
                      hintText: 'Enter your first name',
                      border: OutlineInputBorder(),
                    ),
                    validator: (value) {
                      if (value == null || value.trim().isEmpty) {
                        return 'Please enter your first name';
                      }
                      return null;
                    },
                  ),

                  const SizedBox(height: 16),

                  TextFormField(
                    controller: _lastNameController,
                    decoration: const InputDecoration(
                      labelText: 'Last name',
                      hintText: 'Enter your last name',
                      border: OutlineInputBorder(),
                    ),
                    validator: (value) {
                      if (value == null || value.trim().isEmpty) {
                        return 'Please enter your last name';
                      }
                      return null;
                    },
                  ),

                  const SizedBox(height: 16),

                  TextFormField(
                    controller: _emailController,
                    keyboardType: TextInputType.emailAddress,
                    decoration: const InputDecoration(
                      labelText: 'Email',
                      hintText: 'Enter your email address',
                      border: OutlineInputBorder(),
                    ),
                    validator: (value) {
                      if (value == null || value.trim().isEmpty) {
                        return 'Please enter your email';
                      }

                      if (!value.contains('@')) {
                        return 'Please enter a valid email';
                      }

                      return null;
                    },
                  ),

                  const SizedBox(height: 32),

                  // -------------------------------------------------------
                  // COMBOBOX
                  // -------------------------------------------------------
                  const Text('ComboBox', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),

                  const SizedBox(height: 8),

                  const Text('Select your country.'),

                  const SizedBox(height: 16),

                  DropdownMenu<String>(
                    width: double.infinity,
                    label: const Text('Country'),
                    hintText: 'Select a country',
                    initialSelection: _selectedCountry,
                    dropdownMenuEntries: _countries
                        .map((country) => DropdownMenuEntry<String>(value: country, label: country))
                        .toList(),
                    onSelected: (value) {
                      setState(() {
                        _selectedCountry = value;
                      });
                    },
                  ),

                  const SizedBox(height: 32),

                  // -------------------------------------------------------
                  // DROPDOWN
                  // -------------------------------------------------------
                  const Text('Dropdown', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),

                  const SizedBox(height: 16),

                  DropdownButtonFormField<String>(
                    initialValue: _selectedDepartment,
                    decoration: const InputDecoration(labelText: 'Department', border: OutlineInputBorder()),
                    hint: const Text('Select a department sample hint'),
                    items: _departments
                        .map((department) => DropdownMenuItem<String>(value: department, child: Text(department)))
                        .toList(),
                    onChanged: (value) {
                      setState(() {
                        _selectedDepartment = value;
                      });
                    },
                    validator: (value) {
                      if (value == null) {
                        return 'Please select a department';
                      }
                      return null;
                    },
                  ),

                  const SizedBox(height: 32),

                  // -------------------------------------------------------
                  // DROPDOWN WITH SEMANTIC WRAPPER
                  // -------------------------------------------------------
                  const Text(
                    'Dropdown with Semantics wrapper',
                    style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
                  ),

                  const SizedBox(height: 16),

                  Semantics(
                    container: true,

                    child: DropdownButtonFormField<String>(
                      initialValue: _selectedDepartment,
                      decoration: const InputDecoration(labelText: 'Department', border: OutlineInputBorder()),
                      hint: const Text('Select a department sample hint'),
                      items: _departments
                          .map((department) => DropdownMenuItem<String>(value: department, child: Text(department)))
                          .toList(),
                      onChanged: (value) {
                        setState(() {
                          _selectedDepartment = value;
                        });
                      },
                      validator: (value) {
                        if (value == null) {
                          return 'Please select a department';
                        }
                        return null;
                      },
                    ),
                  ),

                  const SizedBox(height: 32),

                  // -------------------------------------------------------
                  // DROPDOWN 2 WITH SEMANTIC WRAPPER
                  // -------------------------------------------------------
                  const Text(
                    'Dropdown2 with Semantics wrapper',
                    style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
                  ),

                  const SizedBox(height: 16),

                  Semantics(
                    container: true,
                    child: DropdownButtonFormField2<String>(
                      valueListenable: _departmentValue,
                      decoration: const InputDecoration(labelText: 'Department', border: OutlineInputBorder()),
                      hint: const Text('Select a department sample hint'),
                      items: _departments
                          .map((department) => DropdownItem<String>(value: department, child: Text(department)))
                          .toList(),
                      onChanged: (value) {
                        setState(() {
                          _selectedDepartment = value;
                          _departmentValue.value = value;
                        });
                      },
                      validator: (value) {
                        if (value == null) {
                          return 'Please select a department';
                        }
                        return null;
                      },
                    ),
                  ),

                  const SizedBox(height: 32),

                  // -------------------------------------------------------
                  // CHECKBOX GROUP
                  // -------------------------------------------------------
                  const Text('Interests', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),

                  const SizedBox(height: 8),

                  const Text('Select all that apply.'),

                  const SizedBox(height: 8),

                  ..._interests.keys.map((interest) {
                    return CheckboxListTile(
                      contentPadding: EdgeInsets.zero,
                      title: Text(interest),
                      value: _interests[interest],
                      onChanged: (value) {
                        setState(() {
                          _interests[interest] = value ?? false;
                        });
                      },
                    );
                  }),

                  const SizedBox(height: 24),

                  // -------------------------------------------------------
                  // RADIO GROUP
                  // -------------------------------------------------------
                  const Text('Experience Level', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),

                  const SizedBox(height: 8),

                  const Text('Select one option.'),

                  const SizedBox(height: 8),

                  Semantics(
                    container: true,
                    role: SemanticsRole.radioGroup,
                    label: 'Experience Level',
                    tooltip: 'Experience Level tooltip',
                    hint: 'Experience Level hint',
                    child: RadioGroup<String>(
                      groupValue: _experience,
                      onChanged: (value) {
                        setState(() {
                          _experience = value;
                        });
                      },
                      child: Column(
                        children: [
                          RadioListTile<String>(
                            contentPadding: EdgeInsets.zero,
                            title: const Text('Beginner'),
                            value: 'beginner',
                          ),
                          RadioListTile<String>(
                            contentPadding: EdgeInsets.zero,
                            title: const Text('Intermediate'),
                            value: 'intermediate',
                          ),
                          RadioListTile<String>(
                            contentPadding: EdgeInsets.zero,
                            title: const Text('Advanced'),
                            value: 'advanced',
                          ),
                        ],
                      ),
                    ),
                  ),

                  const SizedBox(height: 32),
                  // -------------------------------------------------------
                  // Multiple buttons with same label
                  // -------------------------------------------------------
                  const Text('Bills Section', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),

                  Column(
                    children: [
                      Row(
                        children: [
                          Expanded(child: Text('Jan 2026 Bill \$200')),
                          Semantics(
                            container: true,
                            // excludeSemantics: true,
                            label: 'Pay Jan Bill',
                            button: true,
                            onTap: () => print('Paying bill fpr Jan'),
                            child: ElevatedButton(
                              onPressed: () => print('Paying bill for Jan'),
                              child: const Padding(
                                padding: EdgeInsets.symmetric(vertical: 14),
                                child: Text('Pay Bill'),
                              ),
                            ),
                          ),
                        ],
                      ),
                      const SizedBox(height: 12),
                      Row(
                        children: [
                          Expanded(child: Text('Feb 2026 Bill \$100')),
                          Semantics(
                            container: true,
                            // excludeSemantics: true,
                            label: 'Pay Feb Bill',
                            button: true,
                            onTap: () => print('Paying bill for Feb'),
                            child: ElevatedButton(
                              onPressed: () => print('Paying bill for Feb'),
                              child: const Padding(
                                padding: EdgeInsets.symmetric(vertical: 14),
                                child: Text('Pay Bill'),
                              ),
                            ),
                          ),
                        ],
                      ),
                      const SizedBox(height: 12),
                      Row(
                        children: [
                          Expanded(child: Text('Mar 2026 Bill \$50')),
                          Semantics(
                            container: true,
                            // excludeSemantics: true,
                            label: 'Pay Mar Bill',
                            button: true,
                            onTap: () => print('Paying bill for Mar'),
                            child: ElevatedButton(
                              onPressed: () => print('Paying bill for Mar'),
                              child: const Padding(
                                padding: EdgeInsets.symmetric(vertical: 14),
                                child: Text('Pay Bill'),
                              ),
                            ),
                          ),
                        ],
                      ),
                    ],
                  ),
                  const SizedBox(height: 32),
                  // -------------------------------------------------------
                  // SUBMIT BUTTON
                  // -------------------------------------------------------
                  SizedBox(
                    width: double.infinity,
                    child: Semantics(
                      container: true,
                      // excludeSemantics: true,
                      label: 'Cancel',
                      button: true,
                      onTap: () => print('Cancelling'),
                      child: ElevatedButton(
                        onPressed: () => print('Cancelling'),
                        child: const Padding(padding: EdgeInsets.symmetric(vertical: 14), child: Text('Cancel')),
                      ),
                    ),
                  ),
                  const SizedBox(height: 32),
                  SizedBox(
                    width: double.infinity,
                    child: ElevatedButton(
                      onPressed: _submitForm,
                      child: const Padding(padding: EdgeInsets.symmetric(vertical: 14), child: Text('Submit All')),
                    ),
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}

Screenshots or Video

Screenshots / Video demonstration

[Upload media here]

Logs

Logs
[Paste your logs here]

Flutter Doctor output

Doctor output
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.47.3, on macOS 26.5.2 25F84 darwin-arm64,
    locale en-IN)
[✓] Android toolchain - develop for Android devices (Android SDK version
    35.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 26.6)
[✓] Chrome - develop for the web
[✓] Connected device (2 available)
    ! Error: Browsing on the local area network for iPhone 14. Ensure the
      device is unlocked and attached with a cable or associated with the
      same local area network as this Mac.
      The device must be opted into Developer Mode to connect wirelessly.
      (code -27)
    ! Error: Browsing on the local area network for iPhone 14(26). Ensure
      the device is unlocked and attached with a cable or associated with
      the same local area network as this Mac.
      The device must be opted into Developer Mode to connect wirelessly.
      (code -27)
    ! Error: Browsing on the local area network for iPhone 14 Plus. Ensure
      the device is unlocked and attached with a cable or associated with
      the same local area network as this Mac.
      The device must be opted into Developer Mode to connect wirelessly.
      (code -27)
[✓] Network resources

Activity

  1. suryaKommana2662 commented on Sep 11, 2026

    @suryaKommana2662
    Contributor

    @Mickelle Thanks for the report. I tested the provided reproduction locally on Flutter Web using Chrome.

    My observations from the Chrome DevTools Accessibility pane:

    First name: role: textbox, with an explicit aria-label present (First name Enter your first name).
    Last name: role: textbox, with an explicit aria-label present (Last name Enter your last name).
    Email: role: textbox, with an explicit aria-label present (Email Enter your email address).
    Submit All: role: button, accessible name is Submit All, but aria-label is not specified. The name is coming from the button contents.
    Dropdown2 with Semantics wrapper: role: button, accessible name is Department Select a department sample hint, but aria-label is not specified.

    Therefore, I was able to reproduce the reported missing explicit aria-label behavior for the Submit All button and Dropdown2 with Semantics wrapper. The text input fields did expose an aria-label as expected. Routing to the team-accessibility for the further investigation.

    Flutter doctor Output

    [✓] Flutter (Channel stable, 3.47.3, on macOS 26.4 25E246 darwin-arm64, locale en-IN) [347ms]
        • Flutter version 3.47.3 on channel stable at /Users/suryakommana/develop/flutter
        • Upstream repository https://github.com/flutter/flutter.git
        • Framework revision e8113bf456 (7 days ago), 2026-09-04 13:20:08 -0700
        • Engine revision 06a2e2a110
        • Dart version 3.13.3
        • DevTools version 2.60.0
        • Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets,
          enable-record-use, enable-swift-package-manager, omit-legacy-version-file, enable-lldb-debugging, enable-uiscene-migration
    
    [✓] Android toolchain - develop for Android devices (Android SDK version 36.0.0) [1,564ms]
        • Android SDK at /Users/suryakommana/Library/Android/sdk
        • Emulator version 37.1.11.0 (build_id 15917651) (CL:N/A)
        • Platform android-37.0, build-tools 36.0.0
        • Java binary at: /Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/java
          This is the JDK bundled with the latest Android Studio installation on this machine.
          To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
        • Java version OpenJDK Runtime Environment (build 25.0.2+-15348964-b329.117)
        • All Android licenses accepted.
    
    [✓] Xcode - develop for iOS and macOS (Xcode 26.6) [759ms]
        • Xcode at /Applications/Xcode.app/Contents/Developer
        • Build 17F113
        • CocoaPods version 1.17.0
    
    [✓] Chrome - develop for the web [3ms]
        • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
    
    [✓] Connected device (3 available) [5.9s]
        • iPhone 17 pro (mobile) • DB7548B3-163D-4C89-A357-B948B8CE1F16 • ios            • com.apple.CoreSimulator.SimRuntime.iOS-26-5 (simulator)
        • macOS (desktop)        • macos                                • darwin-arm64   • macOS 26.4 25E246 darwin-arm64
        • Chrome (web)           • chrome                               • web-javascript • Google Chrome 152.0.7977.83
    
    [✓] Network resources [421ms]
        • All expected network resources are available.
    
    • No issues found!
  2. added
    fyi-webFor the attention of Web platform team
    team-accessibilityOwned by Framework Accessibility team (i.e. responsible for accessibility code in flutter/flutter)
    a: accessibilityAccessibility, e.g. VoiceOver or TalkBack. (aka a11y)
    platform-webWeb applications specifically
    and removed
    in triagePresently being triaged by the triage team
    fyi-webFor the attention of Web platform team
    on Sep 11, 2026
  3. added
    team-webOwned by Web platform team
    and removed
    team-accessibilityOwned by Framework Accessibility team (i.e. responsible for accessibility code in flutter/flutter)
    on Sep 23, 2026
  4. flutter-zl commented on Sep 30, 2026

    @flutter-zl
    Contributor

    Hi @Mickelle , I can reproduce the issue you reported in Demo: https://flutter-demo-69-before.web.app/.

    But it was designed like this.

    Design history: buttons used to render aria-label. @yjbanov moved them to DOM text in flutter-team-archive/engine#50794 (2024, commit 8260746, fixing #122607) for two reasons:

    JAWS FreedomScientific/standards-support#759.
    Crawlers ignore aria-label.
    Going back to aria-label alone would bring the JAWS bug back. Adding aria-label on top of the DOM text avoids that, but it has problems of its own:

    Google Translate rewrites DOM text but not aria-label, so on a translated page the name read aloud would stay untranslated while the visible text changes.
    It adds nothing for users, since the name is identical.
    The same argument would then apply to links and headings.

    I am wondering which accessibility tool requires the aria-label for these buttons?

  5. added
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on Sep 30, 2026
  6. Mickelle commented on Oct 7, 2026

    @Mickelle
    Author

    There is no specific tool that requires the aria-label. The voice over announcement is being done as expected by VoiceOver and NVDA.
    The issue is that one of our clients requires the WCAG compliance to be followed.

    We understand that the first rule of ARIA is not to use it unless we have to and this was a recommendation from one of our clients on remediating this issue by meeting the following requirement
    Element should have one of the following:

    1. aria-label attribute
    2. aria-labelledby attribute
    3. title attribute
  7. removed
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on Oct 7, 2026
  8. added
    P2Important issues not at the top of the work list
    triaged-webTriaged by Web platform team
    on Oct 7, 2026
  9. mdebbar commented on Oct 14, 2026

    @mdebbar
    Contributor

    Element should have one of the following:

    1. aria-label attribute
    2. aria-labelledby attribute
    3. title attribute

    I'm not sure where these requirements are coming from. The W3C documentation specifically allows the name of a button to be derived from its contents: https://www.w3.org/TR/accname-1.2/#comp_name_from_content

    And the screenshot you shared actually confirms what W3C says. You can see how it shows 4 options to derive the name from: aria-labelled-by, aria-label, Contents and title. In this case, it used Contents as the name.

    Image
  10. added
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on Oct 14, 2026
  11. Mickelle commented on Oct 16, 2026

    @Mickelle
    Author

    Thank you @mdebbar.
    I have updated the sample code with the 'Billing Section' which is similar to the UI in my app. The screen has multiple buttons with the same label.
    If there is more than one button with the exact same name, the use of an aria-label is recommended to provide a more descriptive name. This would allow screen reader user that are able to bring a list of buttons on a page to differentiate the one that they want to activate.

    Image
  12. removed
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on Oct 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Important issues not at the top of the work lista: accessibilityAccessibility, e.g. VoiceOver or TalkBack. (aka a11y)platform-webWeb applications specificallyteam-webOwned by Web platform teamtriaged-webTriaged by Web platform team

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions