Skip to content
+

Menu v2

Menus display a list of choices on temporary surfaces. Menu v2 adds submenus, checkbox and radio items, and grouping.

Introduction

Menu v2 is a set of components that compose into a menu:

  • Menu: the open state and behavior. It renders no element.
  • Trigger: connects the element that opens the menu.
  • Popup: the positioned menu surface and its transition.
  • Item: an option for users to select.
  • Link Item: an item that navigates.
  • Checkbox Item, Radio Group, and Radio Item: items with a checked state.
  • Submenu, Submenu Trigger, and Submenu Popup: the same parts for a nested menu.
  • Group, Group Label, and Separator: the structure in a menu.
import Menu from '@mui/material/Unstable_Menu2';
import MenuTrigger from '@mui/material/Unstable_Menu2Trigger';
import MenuPopup from '@mui/material/Unstable_Menu2Popup';
import MenuItem from '@mui/material/Unstable_Menu2Item';

Each component is the default export of its own subpath. These examples use local names such as Menu and MenuItem for the components imported from Unstable_Menu2 subpaths. The import paths, theme keys, and CSS classes keep their Menu2 names.

Why a new menu component

Submenus are one of the most requested Menu features since 2018, but the current modal-based Menu can't support them. Menu v2 uses Base UI to provide more menu building blocks and improve keyboard and screen reader support:

  • Nested menus coordinate focus and keyboard navigation. Positioning follows the anchor and flips to avoid collisions.
  • Checkbox and radio items, labeled groups, and link items provide built-in roles and ARIA attributes.
  • Disabled items stay focusable, and content outside the menu remains available to screen readers.
  • The trigger component handles anchor state and ARIA attributes. Opening with a pointer highlights no item. Enter, Space, and ArrowDown highlight the first enabled item; ArrowUp highlights the last enabled item.

Material UI supplies the styles and theming. Base UI is included in @mui/material; no extra installation is needed, and apps that don't import Menu v2 don't bundle it.

See Upgrade to Menu v2 for the architecture, prop mappings, and behavior changes.

Basic menu

Put MenuTrigger and MenuPopup inside Menu. Give MenuTrigger one element, and put the items inside MenuPopup.

import * as React from 'react';
import Button from '@mui/material/Button';
import Menu from '@mui/material/Unstable_Menu2';
import MenuPopup from '@mui/material/Unstable_Menu2Popup';
import MenuTrigger from '@mui/material/Unstable_Menu2Trigger';
import MenuItem from '@mui/material/Unstable_Menu2Item';

export default function BasicMenu2() {
  return (
    <Menu>
      <MenuTrigger>
        <Button>Dashboard</Button>
      </MenuTrigger>
      <MenuPopup>
        <MenuItem>Profile</MenuItem>
        <MenuItem>My account</MenuItem>
        <MenuItem>Logout</MenuItem>
      </MenuPopup>
    </Menu>
  );
}

Selecting a MenuItem closes the menu by default. Set closeOnClick={false} to keep it open. Checkbox, radio, and link items stay open by default; set closeOnClick to close after selection.

Account menu

The trigger can be a composed element, such as an IconButton in a Tooltip. align="end" on MenuPopup aligns the menu with the end of the trigger.

Give the IconButton a unique id so the menu can use it as its accessible label. Tooltip uses its own id prop for the tooltip, not the button.

Contact

Profile

Open a dialog

Keep the Dialog outside the menu so that it stays mounted when the menu closes. Open it from the item's onClick handler.

Keep a ref on the menu trigger and set disableRestoreFocus on Dialog. Focus the trigger from Dialog's slotProps.transition.onExited callback:

<Dialog
  open={dialogOpen}
  onClose={() => setDialogOpen(false)}
  disableRestoreFocus
  slotProps={{
    transition: {
      onExited: () => triggerRef.current?.focus({ preventScroll: true }),
    },
  }}
>
  {/* Dialog content */}
</Dialog>

Keep the trigger mounted and enabled until the dialog closes. This changes Dialog's return target, not Menu v2's focus behavior. The live recipe includes a classic Menu comparison and a control to disable the workaround.

Nest a MenuSubmenu in the item list. Put MenuSubmenuTrigger and MenuSubmenuPopup inside it, with the items inside the popup. The trigger can be wrapped in another component, but regular, link, checkbox, and radio items do not open a submenu.

By default, a submenu opens on mouse hover after 100 ms; a click does not bypass this delay. Set openOnHover={false} on MenuSubmenuTrigger to open with a mouse click instead. Touch taps, Enter, Space, and ArrowRight also open it, or ArrowLeft in right-to-left text. See the trigger API for hover timing and arrow customization. If you remove the arrow, keep another visible cue that the item opens a submenu.

Submenus nest to any depth and flip when they run out of room. Escape closes the innermost submenu and returns focus to its trigger. Pass closeParentOnEsc to MenuSubmenu to close the whole menu.

Icon menu

Compose the same list primitives that you use with the current Menu: ListItemIcon, ListItemText, and Typography for a shortcut hint.

Dense menu

Set dense on the popup's list slot to make all the items compact. Submenus inherit their parent list's density. Set slotProps.list.dense on MenuSubmenuPopup to override it, including false for regular spacing. An item can also set its own dense prop.

Checkbox and radio items

MenuCheckboxItem renders role="menuitemcheckbox" with its own indicator. For a single choice in a set, put MenuRadioItem components in a MenuRadioGroup. Both keep the menu open by default so users can change several options.

Read the new value from the first callback argument: onCheckedChange(checked, eventDetails) or onValueChange(value, eventDetails). For an uncontrolled item or group, use defaultChecked or defaultValue. To change the icons, see Checkbox and radio indicators.

Composed menu

The parts compose freely: checkbox items with shortcut hints, a submenu with a radio group, icon items, and a disabled item.

Grouped menu

MenuGroup and MenuGroupLabel label a set of related items. The group refers to its label with aria-labelledby. Use MenuSeparator between groups.

MenuLinkItem renders a real anchor with role="menuitem", so middle-click, right-click, and keyboard activation behave like a link.

A link item doesn't close the menu on click by default. Set closeOnClick to close it, for example with client-side routing.

Positioned menu

The MenuPopup props side and align place the menu relative to its anchor, and sideOffset and alignOffset move it. The defaults are side="bottom" and align="start".

Use the logical inline-start and inline-end sides to get the correct direction in right-to-left text.

Open the non-modal menu, then change the values for a live preview. Press Escape or use the trigger to close it.

sideOffset: 8 px

alignOffset: 0 px

The menu flips when it collides with the edge of its container, and it follows its anchor on scroll and resize.

Open on hover

Set openOnHover on MenuTrigger to open the menu when the pointer rests on the trigger.

import * as React from 'react';
import Button from '@mui/material/Button';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import Menu from '@mui/material/Unstable_Menu2';
import MenuPopup from '@mui/material/Unstable_Menu2Popup';
import MenuTrigger from '@mui/material/Unstable_Menu2Trigger';
import MenuItem from '@mui/material/Unstable_Menu2Item';

export default function HoverMenu2() {
  return (
    <Menu>
      <MenuTrigger openOnHover closeDelay={150}>
        <Button endIcon={<KeyboardArrowDownIcon />}>Products</Button>
      </MenuTrigger>
      <MenuPopup>
        <MenuItem>Material UI</MenuItem>
        <MenuItem>Base UI</MenuItem>
        <MenuItem>MUI X</MenuItem>
      </MenuPopup>
    </Menu>
  );
}

A menu that opens on hover isn't modal, so the rest of the page stays interactive.

Controlled menu

Pass open and onOpenChange to Menu to control the open state. The trigger still sets the ARIA attributes and receives focus when the menu closes.

Open the menu, then close it.

Keep the menu open

onOpenChange receives the reason for the change in eventDetails.reason. Call eventDetails.cancel() to prevent a close, for example on an outside press. This works for controlled and uncontrolled menus:

<Menu
  onOpenChange={(open, eventDetails) => {
    if (!open && eventDetails.reason === 'outside-press') {
      eventDetails.cancel();
    }
  }}
>
  <MenuTrigger>
    <Button>Options</Button>
  </MenuTrigger>
  <MenuPopup>{/* Menu items */}</MenuPopup>
</Menu>

Without a trigger

Omit MenuTrigger and pass anchor to MenuPopup to position it against an element that you control, the same as anchorEl in the current Menu. The menu can't connect to that element, so you do the wiring:

  • Add aria-haspopup="menu", aria-expanded, and aria-controls to the element. Set the matching id on MenuPopup.
  • Give MenuPopup an accessible name with aria-label or aria-labelledby.
  • Pass finalFocus to MenuPopup to return focus to the element when the menu closes. Otherwise, focus can move to an unrelated element.

Max height menu

The menu limits its height to the viewport and to the space available at the anchor, and scrolls its content. To set a smaller limit with the sx prop on MenuPopup, use min(320px, var(--available-height)) to keep it within short screens.

import * as React from 'react';
import Button from '@mui/material/Button';
import Menu from '@mui/material/Unstable_Menu2';
import MenuPopup from '@mui/material/Unstable_Menu2Popup';
import MenuTrigger from '@mui/material/Unstable_Menu2Trigger';
import MenuItem from '@mui/material/Unstable_Menu2Item';

const countries = [
  'Argentina',
  'Australia',
  'Austria',
  'Belgium',
  'Brazil',
  'Canada',
  'Chile',
  'Colombia',
  'Czechia',
  'Denmark',
  'Estonia',
  'Finland',
  'France',
  'Germany',
  'Greece',
  'Hungary',
  'Iceland',
  'India',
  'Ireland',
  'Italy',
  'Japan',
  'Lithuania',
  'Mexico',
  'Netherlands',
  'New Zealand',
  'Norway',
  'Poland',
  'Portugal',
  'Spain',
  'Sweden',
  'Switzerland',
  'United Kingdom',
];

export default function LongMenu2() {
  return (
    <Menu>
      <MenuTrigger>
        <Button>Country</Button>
      </MenuTrigger>
      <MenuPopup
        sx={{ maxHeight: 'min(320px, var(--available-height))', width: 240 }}
        slotProps={{ list: { dense: true } }}
      >
        {countries.map((country) => (
          <MenuItem key={country}>{country}</MenuItem>
        ))}
      </MenuPopup>
    </Menu>
  );
}

Customization

Style the popup and items with sx, slots, or the MuiMenu2* theme keys. See the theme components guide for defaultProps and styleOverrides, and the popup API reference for the slots and CSS classes.

Slots

Menu and MenuSubmenu render no element and have no slots, DOM ref, or sx prop. On MenuPopup and MenuSubmenuPopup, ref, className, style, sx, event handlers, and HTML attributes target the menu surface, which is the root slot. Use slotProps.positioner for the positioned element and slotProps.portal for the stacking layer. Set positioning props such as side and align on the popup.

The portal carries theme.zIndex.modal and keeps the menu and its modal interaction layer above other content, including Dialog. Submenus stay within the root menu's stacking context, so set slotProps.portal.sx.zIndex on MenuPopup to change the stacking level of the whole menu.

This demo wraps MenuPopup in styled() to set defaults, style the surface directly, and style items through their class selectors:

Trigger

MenuTrigger merges its behavior into the element you supply as its child. Style that element directly, or use a scoped state selector such as .MuiMenu2Trigger-root.Mui-open. Trigger props, event handlers, and the ref belong on MenuTrigger or its child, not on Menu.

Shared theme styles

MuiMenu2Popup.styleOverrides also applies to matching submenu popup slots. MuiMenu2Item.styleOverrides applies to all item roots, including link, checkbox, and radio items and submenu triggers. The dense, divider, and gutters overrides apply when the corresponding state matches. Theme variants are shared too; menu variants apply only to the root slot.

Component-specific styles apply after shared styles, with sx last; CSS specificity still applies. Callbacks receive the rendered component's live state. Utility classes remain component-specific, and defaultProps are not shared between theme keys.

Use scoped state selectors such as .MuiMenu2CheckboxItem-root.Mui-checked inside a slot override, or a theme variant for states such as highlighted.

State data attributes

Menu v2 also provides these Base UI data-* attributes for CSS selectors and Tailwind CSS:

Element Attributes
Root menu trigger data-popup-open, data-pressed, data-disabled
MenuItem root data-highlighted, data-disabled
MenuLinkItem root data-highlighted
MenuCheckboxItem and MenuRadioItem roots and indicators data-highlighted, data-disabled, data-checked, data-unchecked
MenuSubmenuTrigger root data-popup-open, data-highlighted, data-disabled
Popup root slot data-open, data-closed, data-side, data-align, data-starting-style, data-ending-style
positioner slot data-open, data-closed, data-side, data-align
Optional backdrop slot data-open, data-closed, data-starting-style, data-ending-style

State attributes have an empty value when active and are absent otherwise: use [data-checked], not [data-checked="true"]. Checked items and their indicators have either data-checked or data-unchecked. data-side and data-align contain the resolved placement after collision handling.

Triggers use data-popup-open, not data-open. On the root trigger, data-pressed follows the open state and data-disabled follows Menu's disabled prop. data-highlighted follows pointer and keyboard navigation, including focus on disabled items; use .Mui-focusVisible for keyboard focus-visible styles.

The popup's portal and list slots and the submenu arrow have no automatic state attributes. Custom slots must forward the supplied props and ref. Internal data-mui-* markers are not part of this styling contract.

For example, after the Tailwind CSS v4 setup, style a checked item with a presence variant:

<MenuCheckboxItem className="data-[checked]:font-semibold">
  Show toolbar
</MenuCheckboxItem>

Checkbox and radio indicators

Use icon and checkedIcon on MenuCheckboxItem and MenuRadioItem to change the unchecked and checked icons. Custom icons keep their own size.

Use slotProps.indicator to customize the indicator container, or slots.indicator to replace it. If slotProps.indicator.children is neither null nor undefined, it takes precedence over the icon props. Slot callbacks receive the live checked, disabled, and highlighted state. A replacement must forward the supplied props and ref, including the state classes in className.

The default indicators use ListItemIcon, so its selectors also affect indicators. With modular CSS layers, MuiListItemIcon theme overrides take precedence over default indicator colors. For indicator-specific styles, use the item's styleOverrides.indicator or slotProps.indicator.sx; indicators have no separate theme keys. The internal indicator components are not exported, but their owning items export menu2CheckboxItemIndicatorClasses and menu2RadioItemIndicatorClasses for CSS selectors.

Transitions

MenuPopup uses the Grow transition, the same as the current Menu. With the default transitionDuration="auto", the duration depends on the height of the menu. Pass a different transition component to the popup's slots.transition, and its props to slotProps.transition:

import * as React from 'react';
import Button from '@mui/material/Button';
import Fade from '@mui/material/Fade';
import Menu from '@mui/material/Unstable_Menu2';
import MenuPopup from '@mui/material/Unstable_Menu2Popup';
import MenuTrigger from '@mui/material/Unstable_Menu2Trigger';
import MenuItem from '@mui/material/Unstable_Menu2Item';

export default function FadeMenu2() {
  return (
    <Menu>
      <MenuTrigger>
        <Button>Dashboard</Button>
      </MenuTrigger>
      <MenuPopup slots={{ transition: Fade }}>
        <MenuItem>Profile</MenuItem>
        <MenuItem>My account</MenuItem>
        <MenuItem>Logout</MenuItem>
      </MenuPopup>
    </Menu>
  );
}

Grow, Fade, and Zoom work with Menu v2. A custom transition must forward its child's props and ref, animate that same popup element, and add no DOM wrapper. The animation must start in time for Base UI to detect it. Test other transitions before use.

The adapter controls in, appear, mountOnEnter, and unmountOnExit.

Set transitionDuration={0} to remove the animation. To animate with CSS, set slots.transition to null. The menu surface has data-starting-style for its initial style, then removes it to start the enter transition. It has data-ending-style while it leaves:

<MenuPopup
  slots={{ transition: null }}
  slotProps={{
    root: {
      sx: {
        transition: 'opacity 150ms',
        '&[data-starting-style], &[data-ending-style]': { opacity: 0 },
      },
    },
  }}
>
  {/* Menu items */}
</MenuPopup>

Grow and the other Material UI transitions follow theme.motion.reducedMotion. CSS animations need their own reduced-motion handling.

data-closed marks the logical closed state, not the end of the exit transition. With keepMounted, the paper and positioner keep data-closed while hidden after the transition. Without it, they unmount.

Backdrop

Base UI uses an internal, transparent backdrop for modal menus. This layer is absent when a menu opens on hover or has modal={false}.

The optional visual backdrop is separate and is not rendered by default. Set slots.backdrop or slotProps.backdrop on MenuPopup to render it inside the portal, behind the menu. Its default styles are transparent and do not capture pointer events. To dim the page:

<MenuPopup slotProps={{ backdrop: { sx: { bgcolor: 'rgba(0, 0, 0, 0.5)' } } }}>
  {/* Menu items */}
</MenuPopup>

Set slots={{ backdrop: null }} to omit this layer, including when the theme supplies backdrop slot props. This does not change modal behavior. Base UI does not hide this optional layer when the menu opens on hover. Submenus have no backdrop slot.

Accessibility

Menu v2 follows the WAI-ARIA menu button pattern. It handles the trigger, the roles, the keyboard behavior, focus management, and dismissal:

  • The trigger gets aria-haspopup, aria-expanded, and aria-controls.
  • Arrow keys move between items and respect right-to-left text. Home and End move to the first and the last item.
  • Typeahead matches items by their text content, or by the label prop when the content isn't plain text.
  • Disabled items stay focusable, and screen readers announce them as disabled.
  • Escape closes one level at a time and returns focus to the trigger of that level.

The menu takes its accessible name from the trigger. aria-label or aria-labelledby on MenuPopup replaces it, and an explicit aria-labelledby takes precedence over aria-label. Matching attributes on slotProps.root take precedence over the top-level ones.

Two things stay your responsibility:

  • Label an icon-only trigger. Pass aria-label to MenuTrigger or its child.
  • Keep custom item content readable. Icons, secondary text, and shortcut hints in an item need sufficient contrast.