Repository navigation
[web] VoiceOver announces menu items with an off-by-one position/count (N-item menu reads as "2 of N+1") #190661
Description
Use case
On Flutter web, a popup menu's item positions are announced incorrectly by macOS VoiceOver in Chrome. A menu with 10 items announces the first item as "2 of 11" instead of "1 of 10" — every item's position and the total set size are shifted up by one.
This makes the position information VoiceOver reads to blind users wrong for every PopupMenuButton / showMenu (and MenuAnchor) on the web.
NVDA and JAWS on Windows announce the same menu correctly ("1 of 10").
Steps to reproduce
- Run the sample below on web:
flutter run -d chrome. - Enable VoiceOver on macOS (Cmd+F5).
- Focus the "Open menu" button and activate it.
- Arrow through the menu items and listen to the announced position.
Sample code
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: Home()));
class Home extends StatelessWidget {
const Home({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: PopupMenuButton<int>(
child: const Text('Open menu'),
itemBuilder: (context) => <PopupMenuEntry<int>>[
for (var i = 0; i < 10; i++)
PopupMenuItem<int>(value: i, child: Text('Item ${i + 1}')),
],
),
),
);
}
}Expected results
VoiceOver announces the first menu item as "Item 1, 1 of 10".
Actual results
VoiceOver announces the first menu item as "Item 1, 2 of 11" — position and set size are both off by one. The last item reads "11 of 11".
Root cause (framework source)
The framework wraps the menu items in a scrollable inside the role: SemanticsRole.menu node with explicitChildNodes: true. On web, the roled menuitem nodes are hoisted onto the menu via aria-owns, but the SingleChildScrollView remains as a direct child of the menu and is emitted as a role="group" node. macOS VoiceOver + Chrome then count that scroll-container group as an extra member of the menu (at position 1), shifting every real item by one.
Inspecting the live DOM confirms the structure — a 10-item menu produces:
<flt-semantics role="menu" aria-owns="[10 menuitem ids]"> ← correctly owns 10 items
<flt-semantics role="group"> … 10 no-role visual placeholders … </flt-semantics> ← the extra node
The role="menu" therefore has 11 members from VoiceOver's perspective: the scroll-container group + the 10 aria-owns'd menuitems. NVDA/JAWS honor aria-owns and count 10.
This wrapping is in the framework, e.g. _PopupMenu.build (packages/flutter/lib/src/material/popup_menu.dart, Flutter 3.44.2):
Semantics(
role: SemanticsRole.menu,
scopesRoute: true,
namesRoute: true,
explicitChildNodes: true,
label: widget.semanticLabel,
child: SingleChildScrollView( // <-- becomes the extra role="group"
padding: ...,
child: ListBody(children: children),
),
)MenuAnchor reproduces the same behavior — its _MenuPanel.build (packages/flutter/lib/src/material/menu_anchor.dart) also wraps the items in Scrollbar → SingleChildScrollView inside the menu, so switching from showMenu to MenuAnchor does not avoid the extra group / off-by-one.
Affected
- Platform: Flutter web (CanvasKit and HTML/skwasm).
- Screen reader: macOS VoiceOver (Chrome and Safari). NVDA/JAWS on Windows are correct.
- Widgets:
PopupMenuButton/showMenuandMenuAnchor(both go through aSingleChildScrollViewinside the menu role).
flutter doctor / version
Flutter 3.44.2 • channel stable
Framework • revision c9a6c48423 • 2026-06-10
Engine • hash 04efd7c093d4e9281d5526ebcad6ecc60ba8badf
Tools • Dart 3.12.2 • DevTools 2.57.0
Activity
puneetkukreja98 commentedon Aug 7, 2026
@jimmy-tfp Thanks for reporting this issue, I was able to reproduce it on latest stable.
Video:
IMG_9940.MOV
Flutter doctor -v:
chunhtai commentedon Aug 19, 2026
framework does not add the x of y string, it is automatically announced by the voiceover based on the dom tree
yjbanov commentedon Aug 26, 2026
@chunhtai Do you know why the framework injects the intermediate node between the
menuand the menu items? We could try applying something likerole="presentation"in the engine, but the engine would need to know that this role needs to be applied.chunhtai commentedon Aug 26, 2026
I suspect this is for semantics grafting in overlayportal, but I am not too sure. However, why is an empty node still be counted by web? we do have empty container node all the time in framework
yjbanov commentedon Aug 26, 2026
Empty container nodes are a common occurrence, but I guess when a node separates the menu node from menu items, screen readers count them. I wonder if we could set
presentationfor all nodes that are used purely for unlabeled grouping. This needs some investigation.[web] Omit group role on menu scrollables and assign region role to n…
[web] Omit group role on menu scrollables and assign region role to n…
[web] Omit group role on menu scrollables and assign region role to n…
github-actions commentedon Oct 9, 2026
This thread has been automatically locked since there has not been any recent activity after it was closed. If you are still experiencing a similar issue, please open a new bug, including the output of
flutter doctor -vand a minimal reproduction of the issue.