Dev Blog

BLOG

Tidying up a long app menu with React Aria submenus

As features grew, our app menu reached 28 rows, so we grouped related items into submenus and brought it down to 15. What React Aria Components' SubmenuTrigger handles for mouse, keyboard, touch, and screen readers, and how we open submenus below the row on narrow screens.

Published October 15, 2026

As the app gained features, its menu gained items, and it ended up with 28 rows. On a laptop screen the bottom was cut off, and you had to scroll to reach Contact Us.

So we grouped related items into smaller menus that open to the side when you press a row (submenus), bringing the menu down to 15 rows. This post covers how we grouped them, how much of the submenu behavior we could leave to React Aria Components' SubmenuTrigger, and what we still had to build ourselves, such as how submenus open on narrow screens.

From 28 rows to 15

We made six submenus:

  • AI: Ask AI for BPM, Ask AI to Edit Settings
  • Find BPM: from audio, live, tap tempo
  • Practice Tools: tuner, drone, gap click, timer
  • Edit: undo, redo, reset to initial state
  • File: Import Settings File, Export as JSON, Export as MP3
  • Help: Keyboard Shortcuts, Customize Shortcuts, Documentation, Contact Us

Items that are used often or make sense on their own, such as Statistical Charts, SYNC (match with other devices), Save Current Setting, Add Track, Audio settings and Display Settings, stay at the top level of the menu instead of going into a submenu. Undo and redo are mostly used from the keyboard (⌘Z and ⇧⌘Z on a Mac, Ctrl+Z and Ctrl+Shift+Z on Windows), so we decided they could sit one level deeper in the menu.

The app menu before the change. 28 rows, from Statistical Charts to Contact Us, in a single column
The menu before. Every item in one column
The app menu after the change. Rows such as AI, Find BPM and Practice Tools have an arrow on the right, and there are fewer rows. 1 marks the three rows from AI to Practice Tools
The menu after. Rows with an arrow open a submenu
  1. Rows that open a submenu. The arrow on the right shows that pressing the row opens a menu to the side

Submenus are harder to get right in behavior than in looks

Drawing a row that opens a submenu and the submenu's box beside it is not hard. The work is in making it behave naturally with a mouse, a keyboard, touch and a screen reader.

For example, if the submenu opens the moment the pointer enters a row, it flickers as you just move the pointer up and down the menu. Once it is open, moving the pointer diagonally toward the submenu passes over other rows on the way, and the submenu would close. With a keyboard, you have to decide which key opens it, which key closes it, and where focus (the place keyboard input goes) returns after it closes.

The app menu was already built with React Aria Components' Menu, so we built the submenus with SubmenuTrigger from the same library. You wrap the row's MenuItem and the Popover that opens as the submenu in a SubmenuTrigger.

<SubmenuTrigger>
  <MenuItem id="submenu:practiceTools">Practice Tools</MenuItem>
  <Popover>
    <Menu onAction={onAction}>
      <MenuItem id="openTuner">Tuner</MenuItem>
      <MenuItem id="openDrone">Drone</MenuItem>
    </Menu>
  </Popover>
</SubmenuTrigger>

What React Aria takes care of

Reading what is inside SubmenuTrigger (useSubmenuTrigger in @react-aria/menu), we found that it already handles all of the details above.

With a mouse, it waits a moment before opening and stays open on a diagonal move

When the pointer enters a row, the submenu opens 200 ms later (the default delay). Running the pointer up and down the rows does not open it.

Once it is open, the parent menu's rows stop reacting to the pointer while it moves toward the submenu (useSafelyMouseToSubmenu). Whether the pointer is heading there is decided by angle. From the previous pointer position, it takes the directions to the top and bottom edges of the submenu, widens each outward by 15°, and treats the movement as heading to the submenu if its direction falls between them. Passing over other rows on the way does not close the submenu. If the pointer does not move for one second, the rows react normally again.

The pointer is on the Practice Tools row and the submenu has opened to the left. 1 is the Practice Tools row, 2 is the submenu with Tuner, Drone, Gap click and Timer
A submenu opened with the mouse
  1. The row that opened the submenu stays highlighted after the pointer leaves it
  2. There is no room on the right, so it opens to the left

This app's menu opens at the right edge of the screen, so there is no room for the submenu on the right. React Aria works out the position and opens it on the other side (left), and for rows near the bottom of the screen it shifts the submenu up to keep it on screen. The diagonal-move handling also works out the direction when the submenu opens to the left.

With a keyboard, → opens and ← goes back

When a row has focus, pressing → opens the submenu and moves focus to its first item. Inside the submenu, ← or Esc closes only the submenu and returns focus to the row that opened it. In right-to-left languages, → and ← are swapped.

Practice Tools opened with the keyboard. 1 is Tuner, the first item in the submenu, which has focus
A submenu opened with the keyboard
  1. Opening with → moves focus to the first item

When the submenu is opened with the mouse, focus stays where it is; only when it is opened with a keyboard or a screen reader does focus move to the first item. How it was opened is told apart by the pointerType of the press: keyboard for a keyboard and virtual for a screen reader.

Screen readers hear that the row opens a menu and whether it is open

The row gets aria-haspopup="menu" and aria-expanded, and while it is open, aria-controls points to the submenu. The submenu takes the row's name through aria-labelledby, so it is read out as the Practice Tools menu.

Our E2E tests (automated tests that drive the page in a browser) use these attributes too. They check that aria-expanded becomes true after pressing the row, and that the menu found by the row's text has all its items.

const row = page.getByTestId("app-menu-practiceTools-submenu");
await expect(row).toHaveAttribute("aria-expanded", "true");
const submenu = page.getByRole("menu", { name: await row.innerText() });

With touch, it opens when the finger lifts

When you open a submenu by pressing the row, with a mouse click or a touch, it opens when the button or finger is released, not when the press starts. (With a mouse, resting the pointer on the row also opens it, as described above.) If it opened right away, the finger could lift over an item in the newly opened submenu and press that item by accident. The comment in React Aria's source gives the same reason.

Choosing an item closes everything

Choosing an item in a submenu closes the parent menu as well as the submenu (state.closeAll).

The look stays the same as the old menu

React Aria Components has no styles of its own, so the rows and the submenu's box are drawn the same way as the rest of the menu. Following the app's design system (its rules for color, lines and spacing), the box is flat, with no rounded corners or shadows and a 1px border. The row that opens a submenu shows an arrow icon from the design system on the right. We changed the gap between a submenu and its parent menu from SubmenuTrigger's default of 8px to 4px, to match the app's other popovers (small boxes that open next to the button you press).

Row states show up as data attributes, so CSS can style them. The row selected with the keyboard gets data-focused, and the row with an open submenu gets data-open. We keep the open row highlighted, so you can still see which row the submenu came from while the pointer is in it.

const ROW_CLASS = cn(
  "flex h-10 items-center gap-2.5 px-3.5",
  FILL_CLASS.base,
  // The keyboard-selected row and the row with an open submenu get the hover fill
  "data-focused:bg-mfm-base-hover data-open:bg-mfm-base-hover",
  DISABLED_CLASS,
);

What we built ourselves

Passing onAction to the submenus

The onAction passed to the parent Menu is not inherited by the submenu's Menu, because the submenu keeps its own state as a separate Menu. We pass the same onAction to every submenu, so choosing an item at any level sends its id to the same function, which runs that item's action.

Expanding below the row on narrow screens

On a smartphone, the menu takes up most of the screen width. There is no room for a submenu on either side, and with SubmenuTrigger's defaults it overflowed off the left edge of the screen and was barely visible.

Our first fix was, when the screen is narrower than 640px, to open the submenu right below the row you pressed, at the same width, on top of the menu.

The first version, with Practice Tools opened at smartphone width. 1 is the submenu, opened right below the Practice Tools row at the same width, on top of the menu
The first version, which opened the submenu on top of the menu, right below the row
  1. It opens right below the row you pressed, at the same width

After it was released, using it on a smartphone showed three problems.

  • Scrolling the menu with a submenu open moved only the parent menu, leaving the submenu behind
  • Pressing the parent row again did not close the submenu
  • The parent and the submenu had the same width and sat in the same place, so you could not tell where the submenu began

All three remain as long as the submenu is a separate box laid on top of the parent menu. We could have stopped the parent from scrolling while a submenu is open, but the boundary would still be unclear. So on narrow screens we stopped using SubmenuTrigger and list the submenu's items below the parent row instead, like an accordion.

The fixed version, with Practice Tools opened at smartphone width. 1 is the Practice Tools row and the four items listed below it, indented
After the fix, the items are listed below the row, indented
  1. The items are listed below the Practice Tools row, indented. The row's arrow points up

The submenu's items now live inside the parent menu, so they scroll with it. They are indented and separated from the rest by 1px lines above and below. The arrow on the right of the row points down when closed and up when open.

The parent row is a MenuItem that does not close the menu when pressed, and each press toggles it. Only one row can be open at a time; pressing another row closes the previous one.

<MenuItem
  aria-expanded={isOpen}
  shouldCloseOnSelect={false}
  onAction={onToggle}
>
  …
</MenuItem>
{isOpen && (
  <MenuSection aria-label={submenu.label} className="border-y border-mfm-subtle">
    {children}
  </MenuSection>
)}

Without shouldCloseOnSelect={false}, pressing the row closes the whole menu. The toggle row does not run anything. So when the Menu's onAction receives that row's id, we ignore it instead of passing it to the function that runs actions.

On wider screens, submenus still open to the side with SubmenuTrigger.

The E2E tests look up which submenu holds an item in a table

The E2E tests found menu items by data-testid and pressed them. Items moved into a submenu are not on the page until their parent row is opened.

We put the mapping of items to submenus in a single file, and both the menu and the E2E tests read it. The file does not depend on React, so the E2E tests can read it too. The E2E helper that opens the menu and presses an item presses the parent row first when the item is inside a submenu.

export const APP_SUBMENUS = {
  ai: ["askAiForBpm", "askAiToEditSettings"],
  findBpm: ["detectBpmFromAudio", "detectBpmLive", "openTapTempo"],
  practiceTools: ["openTuner", "openDrone", "openGapClick", "openTimer"],
  edit: ["undoBpmSetting", "redoBpmSetting", "resetToInitialState"],
  file: ["importSettingsJson", "exportSettingsJson", "exportAudio"],
  help: ["openKeyboardShortcuts", "editKeyboardShortcuts", "openDocs", "contactUs"],
} as const satisfies Record<string, readonly AppMenuItemId[]>;

Moving an item to another submenu does not require rewriting the tests. The docs pages have a menu too, but it has only five items, so it does not use submenus and lists them at the top level. When the helper cannot find a parent row, it presses the top-level item directly.

Share this post

Post on X