Skip to contents
This page is an adaptation of the related MUI Material UI documentation page.

Menus display a list of choices on temporary surfaces.

A menu displays a list of choices on a temporary surface. It appears when the user interacts with a button, or other control.

How the live demos on this page work. A menu needs an anchor element to position itself. In React, the anchor is kept in a useState() hook. Menu.triggerId() does this for you: give the button an id and pass the same id as triggerId. The menu opens next to the button and closes on a click outside, on Escape or on an item click (pass closeOnItemClick = FALSE to keep it open). Everything runs in the browser. See Overlays with .triggerId.

Introduction

Menus are implemented using a collection of related components:

Basic menu

A basic menu opens over the anchor element by default (this option can be changed via props). When close to a screen edge, a basic menu vertically realigns to make sure that all menu items are completely visible.

You should configure the component so that selecting an option immediately confirms it and closes the menu, as shown in the demo below.

muiMaterialPage(
  CssBaseline(),
  Button(id = "basic-button", `aria-haspopup` = "true", "Dashboard"),
  Menu.triggerId(
    triggerId = "basic-button",
    id = "basic-menu",
    slotProps = list(list = list(`aria-labelledby` = "basic-button")),
    MenuItem("Profile"),
    MenuItem("My account"),
    MenuItem("Logout")
  )
)
JS code
import * as React from 'react';
import Button from '@mui/material/Button';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';

export default function BasicMenu() {
  const id = React.useId();
  const buttonId = `${id}-button`;
  const menuId = `${id}-menu`;
  const [anchorEl, setAnchorEl] = React.useState(null);
  const open = Boolean(anchorEl);
  const handleClick = (event) => {
    setAnchorEl(event.currentTarget);
  };
  const handleClose = () => {
    setAnchorEl(null);
  };

  return (
    <div>
      <Button
        id={buttonId}
        aria-controls={open ? menuId : undefined}
        aria-haspopup="true"
        aria-expanded={open}
        onClick={handleClick}
      >
        Dashboard
      </Button>
      <Menu
        id={menuId}
        anchorEl={anchorEl}
        open={open}
        onClose={handleClose}
        slotProps={{
          list: {
            'aria-labelledby': buttonId,
          },
        }}
      >
        <MenuItem onClick={handleClose}>Profile</MenuItem>
        <MenuItem onClick={handleClose}>My account</MenuItem>
        <MenuItem onClick={handleClose}>Logout</MenuItem>
      </Menu>
    </div>
  );
}

Icon menu

In desktop viewport, padding is increased to give more space to the menu. MenuList() inside a Paper() renders the menu content without the popover, which is how the next demos display it.

muiMaterialPage(
  useMaterialIconsFilled = TRUE,
  CssBaseline(),
  Paper(
    sx = list(width = 320, maxWidth = "100%"),
    MenuList(
      MenuItem(
        ListItemIcon(Icon("content_cut", fontSize = "small")),
        ListItemText("Cut"),
        Typography(variant = "body2", sx = list(color = "text.secondary"), "⌘X")
      ),
      MenuItem(
        ListItemIcon(Icon("content_copy", fontSize = "small")),
        ListItemText("Copy"),
        Typography(variant = "body2", sx = list(color = "text.secondary"), "⌘C")
      ),
      MenuItem(
        ListItemIcon(Icon("content_paste", fontSize = "small")),
        ListItemText("Paste"),
        Typography(variant = "body2", sx = list(color = "text.secondary"), "⌘V")
      ),
      Divider(),
      MenuItem(
        ListItemIcon(Icon("cloud", fontSize = "small")),
        ListItemText("Web Clipboard")
      )
    )
  )
)
JS code
import Divider from '@mui/material/Divider';
import Paper from '@mui/material/Paper';
import MenuList from '@mui/material/MenuList';
import MenuItem from '@mui/material/MenuItem';
import ListItemText from '@mui/material/ListItemText';
import ListItemIcon from '@mui/material/ListItemIcon';
import Typography from '@mui/material/Typography';
import ContentCut from '@mui/icons-material/ContentCut';
import ContentCopy from '@mui/icons-material/ContentCopy';
import ContentPaste from '@mui/icons-material/ContentPaste';
import Cloud from '@mui/icons-material/Cloud';

export default function IconMenu() {
  return (
    <Paper sx={{ width: 320, maxWidth: '100%' }}>
      <MenuList>
        <MenuItem>
          <ListItemIcon>
            <ContentCut fontSize="small" />
          </ListItemIcon>
          <ListItemText>Cut</ListItemText>
          <Typography variant="body2" sx={{ color: 'text.secondary' }}>
            ⌘X
          </Typography>
        </MenuItem>
        <MenuItem>
          <ListItemIcon>
            <ContentCopy fontSize="small" />
          </ListItemIcon>
          <ListItemText>Copy</ListItemText>
          <Typography variant="body2" sx={{ color: 'text.secondary' }}>
            ⌘C
          </Typography>
        </MenuItem>
        <MenuItem>
          <ListItemIcon>
            <ContentPaste fontSize="small" />
          </ListItemIcon>
          <ListItemText>Paste</ListItemText>
          <Typography variant="body2" sx={{ color: 'text.secondary' }}>
            ⌘V
          </Typography>
        </MenuItem>
        <Divider />
        <MenuItem>
          <ListItemIcon>
            <Cloud fontSize="small" />
          </ListItemIcon>
          <ListItemText>Web Clipboard</ListItemText>
        </MenuItem>
      </MenuList>
    </Paper>
  );
}

Dense menu

For the menu that has long list and long text, you can use the dense prop to reduce the padding and text size.

muiMaterialPage(
  useMaterialIconsFilled = TRUE,
  CssBaseline(),
  Paper(
    sx = list(width = 320),
    MenuList(
      dense = TRUE,
      MenuItem(ListItemText(inset = TRUE, "Single")),
      MenuItem(ListItemText(inset = TRUE, "1.15")),
      MenuItem(ListItemText(inset = TRUE, "Double")),
      MenuItem(ListItemIcon(Icon("check")), "Custom: 1.2"),
      Divider(),
      MenuItem(ListItemText("Add space before paragraph")),
      MenuItem(ListItemText("Add space after paragraph")),
      Divider(),
      MenuItem(ListItemText("Custom spacing…"))
    )
  )
)
JS code
import Paper from '@mui/material/Paper';
import Divider from '@mui/material/Divider';
import MenuList from '@mui/material/MenuList';
import MenuItem from '@mui/material/MenuItem';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import Check from '@mui/icons-material/Check';

export default function DenseMenu() {
  return (
    <Paper sx={{ width: 320 }}>
      <MenuList dense>
        <MenuItem>
          <ListItemText inset>Single</ListItemText>
        </MenuItem>
        <MenuItem>
          <ListItemText inset>1.15</ListItemText>
        </MenuItem>
        <MenuItem>
          <ListItemText inset>Double</ListItemText>
        </MenuItem>
        <MenuItem>
          <ListItemIcon>
            <Check />
          </ListItemIcon>
          Custom: 1.2
        </MenuItem>
        <Divider />
        <MenuItem>
          <ListItemText>Add space before paragraph</ListItemText>
        </MenuItem>
        <MenuItem>
          <ListItemText>Add space after paragraph</ListItemText>
        </MenuItem>
        <Divider />
        <MenuItem>
          <ListItemText>Custom spacing…</ListItemText>
        </MenuItem>
      </MenuList>
    </Paper>
  );
}

Selected menu

If used for item selection, when opened, simple menus places the initial focus on the selected menu item. The currently selected menu item is set using the selected prop (from ListItem). To use a selected menu item without impacting the initial focus, set the variant prop to “menu”.

To remember the selection without React state, each option is a reactRouter route (#/lock/<index>): the menu items are links to those routes, and each route renders the list with its own option selected.

options <- c(
  "Show some love to MUI",
  "Show all notification content",
  "Hide sensitive notification content",
  "Hide all notification content"
)

simpleListMenu <- function(selectedIndex) {
  tagList(
    List(
      component = "nav",
      `aria-label` = "Device settings",
      sx = list(bgcolor = "background.paper"),
      ListItemButton(
        id = "lock-button",
        `aria-haspopup` = "listbox",
        `aria-controls` = "lock-menu",
        `aria-label` = "when device is locked",
        ListItemText(primary = "When device is locked", secondary = options[selectedIndex])
      )
    ),
    Menu.triggerId(
      triggerId = "lock-button",
      id = "lock-menu",
      slotProps = list(list = list(`aria-labelledby` = "lock-button", role = "listbox")),
      lapply(seq_along(options), function(index) {
        MenuItem(
          component = "a",
          href = paste0("#/lock/", index),
          disabled = index == 1,
          selected = index == selectedIndex,
          options[index]
        )
      })
    )
  )
}

muiMaterialPage(
  CssBaseline(),
  RouterProvider(
    router = createHashRouter(
      lapply(seq_along(options), function(index) {
        Route(path = paste0("lock/", index), element = simpleListMenu(index))
      }),
      Route(path = "*", element = simpleListMenu(2))
    )
  )
)
JS code
import * as React from 'react';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import MenuItem from '@mui/material/MenuItem';
import Menu from '@mui/material/Menu';

const options = [
  'Show some love to MUI',
  'Show all notification content',
  'Hide sensitive notification content',
  'Hide all notification content',
];

export default function SimpleListMenu() {
  const [anchorEl, setAnchorEl] = React.useState(null);
  const [selectedIndex, setSelectedIndex] = React.useState(1);
  const open = Boolean(anchorEl);
  const handleClickListItem = (event) => {
    setAnchorEl(event.currentTarget);
  };

  const handleMenuItemClick = (event, index) => {
    setSelectedIndex(index);
    setAnchorEl(null);
  };

  const handleClose = () => {
    setAnchorEl(null);
  };

  return (
    <div>
      <List
        component="nav"
        aria-label="Device settings"
        sx={{ bgcolor: 'background.paper' }}
      >
        <ListItemButton
          id="lock-button"
          aria-haspopup="listbox"
          aria-controls="lock-menu"
          aria-label="when device is locked"
          aria-expanded={open}
          onClick={handleClickListItem}
        >
          <ListItemText
            primary="When device is locked"
            secondary={options[selectedIndex]}
          />
        </ListItemButton>
      </List>
      <Menu
        id="lock-menu"
        anchorEl={anchorEl}
        open={open}
        onClose={handleClose}
        slotProps={{
          list: {
            'aria-labelledby': 'lock-button',
            role: 'listbox',
          },
        }}
      >
        {options.map((option, index) => (
          <MenuItem
            key={option}
            disabled={index === 0}
            selected={index === selectedIndex}
            onClick={(event) => handleMenuItemClick(event, index)}
          >
            {option}
          </MenuItem>
        ))}
      </Menu>
    </div>
  );
}

Positioned menu

Because the Menu component uses the Popover component to position itself, you can use the same positioning props to position it. For instance, you can display the menu on top of the anchor:

muiMaterialPage(
  CssBaseline(),
  Button(id = "demo-positioned-button", `aria-haspopup` = "true", "Dashboard"),
  Menu.triggerId(
    triggerId = "demo-positioned-button",
    id = "demo-positioned-menu",
    `aria-labelledby` = "demo-positioned-button",
    anchorOrigin = list(vertical = "top", horizontal = "left"),
    transformOrigin = list(vertical = "top", horizontal = "left"),
    MenuItem("Profile"),
    MenuItem("My account"),
    MenuItem("Logout")
  )
)
JS code
import * as React from 'react';
import Button from '@mui/material/Button';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';

export default function PositionedMenu() {
  const [anchorEl, setAnchorEl] = React.useState(null);
  const open = Boolean(anchorEl);
  const handleClick = (event) => {
    setAnchorEl(event.currentTarget);
  };
  const handleClose = () => {
    setAnchorEl(null);
  };

  return (
    <div>
      <Button
        id="demo-positioned-button"
        aria-controls={open ? 'demo-positioned-menu' : undefined}
        aria-haspopup="true"
        aria-expanded={open}
        onClick={handleClick}
      >
        Dashboard
      </Button>
      <Menu
        id="demo-positioned-menu"
        aria-labelledby="demo-positioned-button"
        anchorEl={anchorEl}
        open={open}
        onClose={handleClose}
        anchorOrigin={{
          vertical: 'top',
          horizontal: 'left',
        }}
        transformOrigin={{
          vertical: 'top',
          horizontal: 'left',
        }}
      >
        <MenuItem onClick={handleClose}>Profile</MenuItem>
        <MenuItem onClick={handleClose}>My account</MenuItem>
        <MenuItem onClick={handleClose}>Logout</MenuItem>
      </Menu>
    </div>
  );
}

Composition with Menu List

The Menu component uses the Popover component internally. But you might want to use a different positioning strategy, or prefer not to block scrolling, for example.

The Menu List component lets you compose your own menu for these kinds of use cases. Its primary purpose is to handle focus. In React, the MUI demo replaces the Menu’s default Popover with a Popper(), anchored with a React ref. Refs are not available from R, so the demo below keeps Menu.triggerId() and uses disableScrollLock = TRUE so that the page stays scrollable while the menu is open. The left part is a MenuList() in a Paper().

muiMaterialPage(
  CssBaseline(),
  Stack(
    direction = "row",
    spacing = 2,
    Paper(
      MenuList(MenuItem("Profile"), MenuItem("My account"), MenuItem("Logout"))
    ),
    Box(
      Button(id = "composition-button", `aria-haspopup` = "true", "Dashboard"),
      Menu.triggerId(
        triggerId = "composition-button",
        id = "composition-menu",
        disableScrollLock = TRUE,
        anchorOrigin = list(vertical = "bottom", horizontal = "left"),
        slots = list(transition = JS("jsmodule['@mui/material'].Grow")),
        MenuItem("Profile"),
        MenuItem("My account"),
        MenuItem("Logout")
      )
    )
  )
)
JS code
import * as React from 'react';
import Button from '@mui/material/Button';
import ClickAwayListener from '@mui/material/ClickAwayListener';
import Grow from '@mui/material/Grow';
import Paper from '@mui/material/Paper';
import Popper from '@mui/material/Popper';
import MenuItem from '@mui/material/MenuItem';
import MenuList from '@mui/material/MenuList';
import Stack from '@mui/material/Stack';

export default function MenuListComposition() {
  const [open, setOpen] = React.useState(false);
  const anchorRef = React.useRef(null);

  const handleToggle = () => {
    setOpen((prevOpen) => !prevOpen);
  };

  const handleClose = (event) => {
    if (anchorRef.current && anchorRef.current.contains(event.target)) {
      return;
    }

    setOpen(false);
  };

  function handleListKeyDown(event) {
    if (event.key === 'Tab') {
      event.preventDefault();
      setOpen(false);
    } else if (event.key === 'Escape') {
      setOpen(false);
    }
  }

  // return focus to the button when we transitioned from !open -> open
  const prevOpen = React.useRef(open);
  React.useEffect(() => {
    if (prevOpen.current === true && open === false) {
      anchorRef.current.focus();
    }

    prevOpen.current = open;
  }, [open]);

  return (
    <Stack direction="row" spacing={2}>
      <Paper>
        <MenuList>
          <MenuItem>Profile</MenuItem>
          <MenuItem>My account</MenuItem>
          <MenuItem>Logout</MenuItem>
        </MenuList>
      </Paper>
      <div>
        <Button
          ref={anchorRef}
          id="composition-button"
          aria-controls={open ? 'composition-menu' : undefined}
          aria-expanded={open}
          aria-haspopup="true"
          onClick={handleToggle}
        >
          Dashboard
        </Button>
        <Popper
          open={open}
          anchorEl={anchorRef.current}
          role={undefined}
          placement="bottom-start"
          transition
          disablePortal
        >
          {({ TransitionProps, placement }) => (
            <Grow
              {...TransitionProps}
              style={{
                transformOrigin:
                  placement === 'bottom-start' ? 'left top' : 'left bottom',
              }}
            >
              <Paper>
                <ClickAwayListener onClickAway={handleClose}>
                  <MenuList
                    autoFocusItem={open}
                    id="composition-menu"
                    aria-labelledby="composition-button"
                    onKeyDown={handleListKeyDown}
                  >
                    <MenuItem onClick={handleClose}>Profile</MenuItem>
                    <MenuItem onClick={handleClose}>My account</MenuItem>
                    <MenuItem onClick={handleClose}>Logout</MenuItem>
                  </MenuList>
                </ClickAwayListener>
              </Paper>
            </Grow>
          )}
        </Popper>
      </div>
    </Stack>
  );
}

Account menu

Menu content can be mixed with other components like Avatar.

muiMaterialPage(
  useMaterialIconsFilled = TRUE,
  CssBaseline(),
  Box(
    sx = list(display = "flex", alignItems = "center", textAlign = "center"),
    Typography(sx = list(minWidth = 100), "Contact"),
    Typography(sx = list(minWidth = 100), "Profile"),
    Tooltip(
      title = "Account settings",
      IconButton(
        id = "account-button",
        size = "small",
        sx = list(ml = 2),
        `aria-haspopup` = "true",
        Avatar(sx = list(width = 32, height = 32), "M")
      )
    )
  ),
  Menu.triggerId(
    triggerId = "account-button",
    id = "account-menu",
    slotProps = list(
      paper = list(
        elevation = 0,
        sx = list(
          overflow = "visible",
          filter = "drop-shadow(0px 2px 8px rgba(0,0,0,0.32))",
          mt = 1.5,
          "& .MuiAvatar-root" = list(width = 32, height = 32, ml = -0.5, mr = 1),
          "&::before" = list(
            content = '""',
            display = "block",
            position = "absolute",
            top = 0,
            right = 14,
            width = 10,
            height = 10,
            bgcolor = "background.paper",
            transform = "translateY(-50%) rotate(45deg)",
            zIndex = 0
          )
        )
      )
    ),
    transformOrigin = list(horizontal = "right", vertical = "top"),
    anchorOrigin = list(horizontal = "right", vertical = "bottom"),
    MenuItem(Avatar(), " Profile"),
    MenuItem(Avatar(), " My account"),
    Divider(),
    MenuItem(ListItemIcon(Icon("person_add", fontSize = "small")), "Add another account"),
    MenuItem(ListItemIcon(Icon("settings", fontSize = "small")), "Settings"),
    MenuItem(ListItemIcon(Icon("logout", fontSize = "small")), "Logout")
  )
)
JS code
import * as React from 'react';
import Box from '@mui/material/Box';
import Avatar from '@mui/material/Avatar';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import ListItemIcon from '@mui/material/ListItemIcon';
import Divider from '@mui/material/Divider';
import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
import Tooltip from '@mui/material/Tooltip';
import PersonAdd from '@mui/icons-material/PersonAdd';
import Settings from '@mui/icons-material/Settings';
import Logout from '@mui/icons-material/Logout';

export default function AccountMenu() {
  const [anchorEl, setAnchorEl] = React.useState(null);
  const open = Boolean(anchorEl);
  const handleClick = (event) => {
    setAnchorEl(event.currentTarget);
  };
  const handleClose = () => {
    setAnchorEl(null);
  };
  return (
    <React.Fragment>
      <Box sx={{ display: 'flex', alignItems: 'center', textAlign: 'center' }}>
        <Typography sx={{ minWidth: 100 }}>Contact</Typography>
        <Typography sx={{ minWidth: 100 }}>Profile</Typography>
        <Tooltip title="Account settings">
          <IconButton
            onClick={handleClick}
            size="small"
            sx={{ ml: 2 }}
            aria-controls={open ? 'account-menu' : undefined}
            aria-haspopup="true"
            aria-expanded={open}
          >
            <Avatar sx={{ width: 32, height: 32 }}>M</Avatar>
          </IconButton>
        </Tooltip>
      </Box>
      <Menu
        anchorEl={anchorEl}
        id="account-menu"
        open={open}
        onClose={handleClose}
        onClick={handleClose}
        slotProps={{
          paper: {
            elevation: 0,
            sx: {
              overflow: 'visible',
              filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))',
              mt: 1.5,
              '& .MuiAvatar-root': {
                width: 32,
                height: 32,
                ml: -0.5,
                mr: 1,
              },
              '&::before': {
                content: '""',
                display: 'block',
                position: 'absolute',
                top: 0,
                right: 14,
                width: 10,
                height: 10,
                bgcolor: 'background.paper',
                transform: 'translateY(-50%) rotate(45deg)',
                zIndex: 0,
              },
            },
          },
        }}
        transformOrigin={{ horizontal: 'right', vertical: 'top' }}
        anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
      >
        <MenuItem onClick={handleClose}>
          <Avatar /> Profile
        </MenuItem>
        <MenuItem onClick={handleClose}>
          <Avatar /> My account
        </MenuItem>
        <Divider />
        <MenuItem onClick={handleClose}>
          <ListItemIcon>
            <PersonAdd fontSize="small" />
          </ListItemIcon>
          Add another account
        </MenuItem>
        <MenuItem onClick={handleClose}>
          <ListItemIcon>
            <Settings fontSize="small" />
          </ListItemIcon>
          Settings
        </MenuItem>
        <MenuItem onClick={handleClose}>
          <ListItemIcon>
            <Logout fontSize="small" />
          </ListItemIcon>
          Logout
        </MenuItem>
      </Menu>
    </React.Fragment>
  );
}

Customization

Here is an example of customizing the component. You can learn more about this in the overrides documentation page. In R, the styled() wrapper of the original demo becomes nested selectors in the sx argument of the menu root.

muiMaterialPage(
  useMaterialIconsFilled = TRUE,
  CssBaseline(),
  Button(
    id = "demo-customized-button",
    `aria-haspopup` = "true",
    variant = "contained",
    disableElevation = TRUE,
    endIcon = Icon("keyboard_arrow_down"),
    "Options"
  ),
  Menu.triggerId(
    triggerId = "demo-customized-button",
    id = "demo-customized-menu",
    elevation = 0,
    anchorOrigin = list(vertical = "bottom", horizontal = "right"),
    transformOrigin = list(vertical = "top", horizontal = "right"),
    slotProps = list(list = list(`aria-labelledby` = "demo-customized-button")),
    sx = list(
      "& .MuiPaper-root" = list(
        borderRadius = "6px",
        marginTop = 1,
        minWidth = 180,
        color = "rgb(55, 65, 81)",
        boxShadow = paste(
          "rgb(255, 255, 255) 0px 0px 0px 0px, rgba(0, 0, 0, 0.05) 0px 0px 0px 1px,",
          "rgba(0, 0, 0, 0.1) 0px 10px 15px -3px, rgba(0, 0, 0, 0.05) 0px 4px 6px -2px"
        ),
        "& .MuiMenu-list" = list(padding = "4px 0"),
        "& .MuiMenuItem-root" = list(
          "& .MuiIcon-root" = list(fontSize = 18, color = "text.secondary", marginRight = 1.5),
          "&:active" = list(backgroundColor = "rgba(25, 118, 210, 0.08)")
        )
      )
    ),
    MenuItem(disableRipple = TRUE, Icon("edit"), "Edit"),
    MenuItem(disableRipple = TRUE, Icon("file_copy"), "Duplicate"),
    Divider(sx = list(my = 0.5)),
    MenuItem(disableRipple = TRUE, Icon("archive"), "Archive"),
    MenuItem(disableRipple = TRUE, Icon("more_horiz"), "More")
  )
)
JS code
import * as React from 'react';
import { styled, alpha } from '@mui/material/styles';
import Button from '@mui/material/Button';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import EditIcon from '@mui/icons-material/Edit';
import Divider from '@mui/material/Divider';
import ArchiveIcon from '@mui/icons-material/Archive';
import FileCopyIcon from '@mui/icons-material/FileCopy';
import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';

const StyledMenu = styled((props) => (
  <Menu
    elevation={0}
    anchorOrigin={{
      vertical: 'bottom',
      horizontal: 'right',
    }}
    transformOrigin={{
      vertical: 'top',
      horizontal: 'right',
    }}
    {...props}
  />
))(({ theme }) => ({
  '& .MuiPaper-root': {
    borderRadius: 6,
    marginTop: theme.spacing(1),
    minWidth: 180,
    color: 'rgb(55, 65, 81)',
    boxShadow:
      'rgb(255, 255, 255) 0px 0px 0px 0px, rgba(0, 0, 0, 0.05) 0px 0px 0px 1px, rgba(0, 0, 0, 0.1) 0px 10px 15px -3px, rgba(0, 0, 0, 0.05) 0px 4px 6px -2px',
    '& .MuiMenu-list': {
      padding: '4px 0',
    },
    '& .MuiMenuItem-root': {
      '& .MuiSvgIcon-root': {
        fontSize: 18,
        color: theme.palette.text.secondary,
        marginRight: theme.spacing(1.5),
        ...theme.applyStyles('dark', {
          color: 'inherit',
        }),
      },
      '&:active': {
        backgroundColor: alpha(
          theme.palette.primary.main,
          theme.palette.action.selectedOpacity,
        ),
      },
    },
    ...theme.applyStyles('dark', {
      color: theme.palette.grey[300],
    }),
  },
}));

export default function CustomizedMenus() {
  const [anchorEl, setAnchorEl] = React.useState(null);
  const open = Boolean(anchorEl);
  const handleClick = (event) => {
    setAnchorEl(event.currentTarget);
  };
  const handleClose = () => {
    setAnchorEl(null);
  };

  return (
    <div>
      <Button
        id="demo-customized-button"
        aria-controls={open ? 'demo-customized-menu' : undefined}
        aria-haspopup="true"
        aria-expanded={open}
        variant="contained"
        disableElevation
        onClick={handleClick}
        endIcon={<KeyboardArrowDownIcon />}
      >
        Options
      </Button>
      <StyledMenu
        id="demo-customized-menu"
        slotProps={{
          list: {
            'aria-labelledby': 'demo-customized-button',
          },
        }}
        anchorEl={anchorEl}
        open={open}
        onClose={handleClose}
      >
        <MenuItem onClick={handleClose} disableRipple>
          <EditIcon />
          Edit
        </MenuItem>
        <MenuItem onClick={handleClose} disableRipple>
          <FileCopyIcon />
          Duplicate
        </MenuItem>
        <Divider sx={{ my: 0.5 }} />
        <MenuItem onClick={handleClose} disableRipple>
          <ArchiveIcon />
          Archive
        </MenuItem>
        <MenuItem onClick={handleClose} disableRipple>
          <MoreHorizIcon />
          More
        </MenuItem>
      </StyledMenu>
    </div>
  );
}

The MenuItem is a wrapper around ListItem with some additional styles. You can use the same list composition features with the MenuItem component.

🎨 If you are looking for inspiration, you can check MUI Treasury’s customization examples.

Max height menu

If the height of a menu prevents all menu items from being displayed, the menu can scroll internally.

ringtones <- c(
  "None", "Atria", "Callisto", "Dione", "Ganymede", "Hangouts Call", "Luna",
  "Oberon", "Phobos", "Pyxis", "Sedna", "Titania", "Triton", "Umbriel"
)
itemHeight <- 48

muiMaterialPage(
  useMaterialIconsFilled = TRUE,
  CssBaseline(),
  IconButton(`aria-label` = "more", id = "long-button", `aria-haspopup` = "true", Icon("more_vert")),
  Menu.triggerId(
    triggerId = "long-button",
    id = "long-menu",
    slotProps = list(
      paper = list(style = list(maxHeight = itemHeight * 4.5, width = "20ch")),
      list = list(`aria-labelledby` = "long-button")
    ),
    lapply(ringtones, function(option) MenuItem(selected = option == "Pyxis", option))
  )
)
JS code
import * as React from 'react';
import IconButton from '@mui/material/IconButton';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import MoreVertIcon from '@mui/icons-material/MoreVert';

const options = [
  'None',
  'Atria',
  'Callisto',
  'Dione',
  'Ganymede',
  'Hangouts Call',
  'Luna',
  'Oberon',
  'Phobos',
  'Pyxis',
  'Sedna',
  'Titania',
  'Triton',
  'Umbriel',
];

const ITEM_HEIGHT = 48;

export default function LongMenu() {
  const [anchorEl, setAnchorEl] = React.useState(null);
  const open = Boolean(anchorEl);
  const handleClick = (event) => {
    setAnchorEl(event.currentTarget);
  };
  const handleClose = () => {
    setAnchorEl(null);
  };

  return (
    <div>
      <IconButton
        aria-label="more"
        id="long-button"
        aria-controls={open ? 'long-menu' : undefined}
        aria-expanded={open}
        aria-haspopup="true"
        onClick={handleClick}
      >
        <MoreVertIcon />
      </IconButton>
      <Menu
        id="long-menu"
        anchorEl={anchorEl}
        open={open}
        onClose={handleClose}
        slotProps={{
          paper: {
            style: {
              maxHeight: ITEM_HEIGHT * 4.5,
              width: '20ch',
            },
          },
          list: {
            'aria-labelledby': 'long-button',
          },
        }}
      >
        {options.map((option) => (
          <MenuItem key={option} selected={option === 'Pyxis'} onClick={handleClose}>
            {option}
          </MenuItem>
        ))}
      </Menu>
    </div>
  );
}

Limitations

There is a flexbox bug that prevents text-overflow: ellipsis from working in a flexbox layout. You can use the Typography component with noWrap to workaround this issue:

muiMaterialPage(
  useMaterialIconsFilled = TRUE,
  CssBaseline(),
  Paper(
    sx = list(width = 230),
    MenuList(
      MenuItem(
        ListItemIcon(Icon("send", fontSize = "small")),
        Typography(variant = "inherit", "A short message")
      ),
      MenuItem(
        ListItemIcon(Icon("priority_high", fontSize = "small")),
        Typography(variant = "inherit", "A very long text that overflows")
      ),
      MenuItem(
        ListItemIcon(Icon("drafts", fontSize = "small")),
        Typography(variant = "inherit", noWrap = TRUE, "A very long text that overflows")
      )
    )
  )
)
JS code
import MenuList from '@mui/material/MenuList';
import MenuItem from '@mui/material/MenuItem';
import Paper from '@mui/material/Paper';
import ListItemIcon from '@mui/material/ListItemIcon';
import Typography from '@mui/material/Typography';
import DraftsIcon from '@mui/icons-material/Drafts';
import SendIcon from '@mui/icons-material/Send';
import PriorityHighIcon from '@mui/icons-material/PriorityHigh';

export default function TypographyMenu() {
  return (
    <Paper sx={{ width: 230 }}>
      <MenuList>
        <MenuItem>
          <ListItemIcon>
            <SendIcon fontSize="small" />
          </ListItemIcon>
          <Typography variant="inherit">A short message</Typography>
        </MenuItem>
        <MenuItem>
          <ListItemIcon>
            <PriorityHighIcon fontSize="small" />
          </ListItemIcon>
          <Typography variant="inherit">A very long text that overflows</Typography>
        </MenuItem>
        <MenuItem>
          <ListItemIcon>
            <DraftsIcon fontSize="small" />
          </ListItemIcon>
          <Typography variant="inherit" noWrap>
            A very long text that overflows
          </Typography>
        </MenuItem>
      </MenuList>
    </Paper>
  );
}

Change transition

Use slots$transition and slotProps$transition to use a different transition. A component is expected, so it is passed as a JavaScript reference with JS().

muiMaterialPage(
  CssBaseline(),
  Button(id = "fade-button", `aria-haspopup` = "true", "Dashboard"),
  Menu.triggerId(
    triggerId = "fade-button",
    id = "fade-menu",
    slotProps = list(list = list(`aria-labelledby` = "fade-button")),
    slots = list(transition = JS("jsmodule['@mui/material'].Fade")),
    MenuItem("Profile"),
    MenuItem("My account"),
    MenuItem("Logout")
  )
)
JS code
import * as React from 'react';
import Button from '@mui/material/Button';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Fade from '@mui/material/Fade';

export default function FadeMenu() {
  const [anchorEl, setAnchorEl] = React.useState(null);
  const open = Boolean(anchorEl);
  const handleClick = (event) => {
    setAnchorEl(event.currentTarget);
  };
  const handleClose = () => {
    setAnchorEl(null);
  };

  return (
    <div>
      <Button
        id="fade-button"
        aria-controls={open ? 'fade-menu' : undefined}
        aria-haspopup="true"
        aria-expanded={open}
        onClick={handleClick}
      >
        Dashboard
      </Button>
      <Menu
        id="fade-menu"
        slotProps={{
          list: {
            'aria-labelledby': 'fade-button',
          },
        }}
        slots={{ transition: Fade }}
        anchorEl={anchorEl}
        open={open}
        onClose={handleClose}
      >
        <MenuItem onClick={handleClose}>Profile</MenuItem>
        <MenuItem onClick={handleClose}>My account</MenuItem>
        <MenuItem onClick={handleClose}>Logout</MenuItem>
      </Menu>
    </div>
  );
}

Context menu

The MUI documentation shows a context menu that opens at the mouse position on a right click. Menu.triggerId() opens on a regular click only, and positioning at the cursor needs the click coordinates, so this demo has no R equivalent without custom JavaScript.

Grouped Menu

Display categories with the ListSubheader component.

listHeader <- function(label) {
  ListSubheader(sx = list(backgroundImage = "var(--Paper-overlay)"), label)
}

muiMaterialPage(
  CssBaseline(),
  Button(id = "grouped-button", `aria-haspopup` = "true", "Dashboard"),
  Menu.triggerId(
    triggerId = "grouped-button",
    id = "grouped-menu",
    slotProps = list(list = list(`aria-labelledby` = "grouped-button", sx = list(py = 0))),
    listHeader("Category 1"),
    MenuItem("Option 1"),
    MenuItem("Option 2"),
    listHeader("Category 2"),
    MenuItem("Option 3"),
    MenuItem("Option 4")
  )
)
JS code
import * as React from 'react';
import Button from '@mui/material/Button';
import ListSubheader from '@mui/material/ListSubheader';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { styled } from '@mui/material/styles';

const StyledListHeader = styled(ListSubheader)({
  backgroundImage: 'var(--Paper-overlay)',
});

export default function GroupedMenu() {
  const id = React.useId();
  const buttonId = `${id}-button`;
  const menuId = `${id}-menu`;
  const [anchorEl, setAnchorEl] = React.useState(null);
  const open = Boolean(anchorEl);
  const handleClick = (event) => {
    setAnchorEl(event.currentTarget);
  };
  const handleClose = () => {
    setAnchorEl(null);
  };

  return (
    <div>
      <Button
        id={buttonId}
        aria-controls={open ? menuId : undefined}
        aria-haspopup="true"
        aria-expanded={open}
        onClick={handleClick}
      >
        Dashboard
      </Button>
      <Menu
        id={menuId}
        anchorEl={anchorEl}
        open={open}
        onClose={handleClose}
        slotProps={{
          list: {
            'aria-labelledby': buttonId,
            sx: {
              py: 0,
            },
          },
        }}
      >
        <StyledListHeader>Category 1</StyledListHeader>
        <MenuItem onClick={handleClose}>Option 1</MenuItem>
        <MenuItem onClick={handleClose}>Option 2</MenuItem>
        <StyledListHeader>Category 2</StyledListHeader>
        <MenuItem onClick={handleClose}>Option 3</MenuItem>
        <MenuItem onClick={handleClose}>Option 4</MenuItem>
      </Menu>
    </div>
  );
}

Menu.triggerId() works unchanged in Shiny. To know which item was clicked on the server, make each item a MenuItem.shinyInput(). Each one reports its own click counter:

library(shiny)
library(muiMaterial)

ui <- muiMaterialPage(
  CssBaseline(),
  Button(id = "actions", "Actions"),
  Menu.triggerId(
    triggerId = "actions",
    MenuItem.shinyInput("action_edit", "Edit"),
    MenuItem.shinyInput("action_delete", "Delete")
  ),
  verbatimTextOutput("last")
)

server <- function(input, output, session) {
  last <- reactiveVal("none")
  observeEvent(input$action_edit, last("Edit"))
  observeEvent(input$action_delete, last("Delete"))
  output$last <- renderText(last())
}

shinyApp(ui, server)

With many generated items, a single custom event carrying the item as payload is lighter than one input per item: MenuItem(onClick = JS("() => Shiny.setInputValue('menu', 'edit', {priority: 'event'})"), "Edit").

Menu.shinyInput() is also available. The server opens and closes the menu with updateMenu.shinyInput(), and input[[inputId]] counts clicks inside the menu without telling you which item was clicked. Run a complete example with muiMaterialExample("Menu").