The Definitive MUI MenuButton and Menu Example (With TypeScript!)

Menu buttons are common components in web development. Interestingly, MUI doesn’t currently offer an out-of-the-box MenuButton component. However, they do have a compositional component in the docs that we will massively customize in this tutorial.

We will add a dynamic icon to the button that rotates based on Menu open state. Then we will explore adding links, icons, and custom position to the Menu. Take a look at the screenshot below of the example we will build (and notice the small top/left offset).

MUI MenuButton with Icons, Links, and Menu offset

Full code and related links are in the Resources section.

Material-UI MenuButton

The MUI docs (linked above) provide scaffolding for a basic menu button. It relies on a third-party library called material-ui-popup-state. The library has a provider called PopupState that wraps an MUI Button and Menu and keeps track of open and close state.

Here’s a guide to MUI Button color.

Here’s what the docs example using material-ui-popup-state looks like (I changed the data).

Unstyled MUI MenuButton
Unstyled MUI MenuButton

A few enhancements are needed to make this look nice. At a minimum I want an icon on the button that flips up or down based on open state. Next I want the menu width to be determined by the button, not the menu content (this is a stylistic choice).

MUI MenuButton Icon

I decided to pass a ‘Down Arrow’ or ‘Up Arrow’ icon to the MUI Button endIcon prop instead of simply rotating a single icon to indicate menu open or close. This is easier from a code perspective.

The material-ui-popup-state provider supplied an observable state value called popupState that can be used to determine which icon to show.

endIcon={
  popupState.isOpen ? <ArrowDropUpIcon /> : <ArrowDropDownIcon />
}

MUI Menu Width and Position

If we want Menu width to be dynamically based on the Button, we need to add a ref to the Button that can detect Button width. We can also use this ref to get position values from the Button and update top/bottom/left/right values for the Menu.

I created a width and position state value, and also a ref called menuRef. The ref is of TypeScript type <HTMLButtonElement>.

Take a look at the useEffect hook below. In order to have clean code and not repeat the menuRef.current.getBoundingClientRect call, I save it to a const and reuse it. This function obtains many sizing and positioning values of the HTML target (the button).

useEffect(() => {
  const menuHTML = menuRef.current ? menuRef.current.getBoundingClientRect(): {width: 0, height: 0, left: 0, top: 0};
  setWidth(
    menuHTML.width
  );
  setPosition({
    left: menuHTML.left + 5,
    top: menuHTML.top + menuHTML.height - 5,
  });
}, [menuRef]);

//JSX
<Button
  ref={menuRef}
/>

Then I pass the values for width, left, and top to the state setters. Notice that top needs to be adjusted for the height of the button.

The docs have a useful example of positioning based on the anchorEl (the button, in this case). It doesn’t allow for as precise of positioning as using a ref, but it will likely serve the needs of most devs. Here are the MUI Menu anchorEl docs.

Material-UI MenuItem

MUI Menus are composed of MenuItems. Here’s the most basic MenuItem I render in my tutorial:

const carModels = ["Tesla", "Toyota", "Ford", "Delorean"];

const menuItemBorderBottom = {
  borderBottom: `1px solid ${theme.palette.success.main}`,
};

//JSX inside of carModels.map
<MenuItem
  onClick={popupState.close}
  sx={
    index !== carModels.length - 1 ? menuItemBorderBottom : {}
  }
>
  {model}
</MenuItem>

First, we make sure to set popupState to close in the PopupState provider.

Next, we need to handle styling. I added a bottom border that matches the Button background color. However, I don’t want a bottom border on the last MenuItem, so I conditionally style the MenuItems based on the index of the array that I am rendering. Full code is at the end of the article.

Material-UI MenuItem Link

If we want a MenuItem to render as a Link component, we need to pass Link in the component prop:

<MenuItem
  onClick={popupState.close}
  component={Link}
  href="https://www.youtube.com/channel/UCb6AZy0_D1y661PMZck3jOw"
  target="_blank"
  sx={{
    ...menuItemBorderBottom,
    color: "primary.main",
    textDecoration: "underline",
  }}
>
  {model}
</MenuItem>

Notice how I passed href and target attributes. When you pass Link as the component, these props pass through to the Link. The Link component has an underline prop, but this didn’t pass through properly when I tried it.

Initially I attempted to render an anchor element instead of passing a Link. The problem with this was that only the exact text of the MenuItem was clickable as a link.

Another interesting problem is that the default MUI Link color and text decoration were overwritten by MenuItem styling. To fix this, I manually styled as a link using the sx prop.

Material-UI Menu Icons

There are several methods for adding icons to a MenuItem. The docs recommend passing a ListItemIcon component which then wraps the desired icon:

<MenuItem onClick={popupState.close} sx={menuItemBorderBottom}>
  <ListItemIcon sx={{color: 'success.main'}}>
    <DirectionsCarIcon fontSize="small" />
  </ListItemIcon>
  <ListItemText>{model}</ListItemText>
</MenuItem>

The docs also recommend using ListItemText, but in my experimenting that was not necessary. It added an extra div so it may not be worth the extra DOM weight unless you want to use the default ListItemText classes for creating nested selectors.

Resources and Related Links

MUI MenuButton Docs

import { useEffect, useRef, useState } from "react";
import Button from "@mui/material/Button";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import Link from "@mui/material/Link";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import PopupState, { bindTrigger, bindMenu } from "material-ui-popup-state";
import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown";
import ArrowDropUpIcon from "@mui/icons-material/ArrowDropUp";
import DirectionsCarIcon from '@mui/icons-material/DirectionsCar';
import { createTheme } from "@mui/material/styles";

export default function NestedMenu() {
  const [width, setWidth] = useState(0);
  const [position, setPosition] = useState({left: 0, top: 0});
  const menuRef = useRef<HTMLButtonElement>(null);
  const theme = createTheme();

  const menuItemBorderBottom = {
    borderBottom: `1px solid ${theme.palette.success.main}`,
  };

  const carModels = ["Tesla", "Toyota", "Ford", "Delorean"];

  useEffect(() => {
    const menuHTML = menuRef.current ? menuRef.current.getBoundingClientRect(): {width: 0, height: 0, left: 0, top: 0};
    setWidth(
      menuHTML.width
    );
    setPosition({
      left: menuHTML.left + 5,
      top: menuHTML.top + menuHTML.height - 5,
    });
  }, [menuRef]);

  return (
    <PopupState variant="popover" popupId="demo-popup-menu">
      {(popupState) => (
        <>
          <Button
            ref={menuRef}
            sx={{
              backgroundColor: "success.main",
              "&:hover": { backgroundColor: "success.dark" },
            }}
            variant="contained"
            {...bindTrigger(popupState)}
            endIcon={
              popupState.isOpen ? <ArrowDropUpIcon /> : <ArrowDropDownIcon />
            }
          >
            Car Models
          </Button>
          <Menu {...bindMenu(popupState)} PaperProps={{ sx: { width: width, left: `${position.left}px !important`, top: `${position.top}px !important`} }}>
            {carModels.map((model, index) => {
              if (index === 0) {
                return (
                  <MenuItem
                    onClick={popupState.close}
                    component={Link}
                    href="https://www.youtube.com/channel/UCb6AZy0_D1y661PMZck3jOw"
                    target="_blank"
                    sx={{
                      ...menuItemBorderBottom,
                      color: "primary.main",
                      textDecoration: "underline",
                    }}
                  >
                    {model}
                  </MenuItem>
                );
              } if(index === 1) {
                return (
                  <MenuItem onClick={popupState.close} sx={menuItemBorderBottom}>
                    <ListItemIcon sx={{color: 'success.main'}}>
                      <DirectionsCarIcon fontSize="small" />
                    </ListItemIcon>
                    <ListItemText>{model}</ListItemText>
                  </MenuItem>
                );
              }
              return (
                <MenuItem
                  onClick={popupState.close}
                  sx={
                    index !== carModels.length - 1 ? menuItemBorderBottom : {}
                  }
                >
                  {model}
                </MenuItem>
              );

              //href="https://www.youtube.com/channel/UCb6AZy0_D1y661PMZck3jOw" target="_blank">
            })}
          </Menu>
        </>
      )}
    </PopupState>
  );
}

Share this post:

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.