Menu

The Menu component provide end users with a list of options on temporary surfaces.

Give FeedbackWAI-ARIABundle Size
'use client';
import * as React from 'react';
import {
  Menu,
  MenuItems,
  MenuItem,
  MenuTrigger,
  MenuSeparator,
  MenuGroup,
  MenuGroupLabel,
} from './Menu';

export default function MenuIntroduction() {
  const createHandleMenuClick = (menuItem: string) => {
    return () => {
      console.log(`Clicked on ${menuItem}`);
    };
  };

  return (
    <Menu>
      <MenuTrigger>My account</MenuTrigger>

      <MenuItems>
        <MenuGroup>
          <MenuGroupLabel>Settings</MenuGroupLabel>
          <MenuItem onClick={createHandleMenuClick('Profile')}>Profile</MenuItem>
          <MenuItem onClick={createHandleMenuClick('Language settings')}>
            Language settings
          </MenuItem>
        </MenuGroup>
        <MenuSeparator />
        <MenuItem onClick={createHandleMenuClick('Log out')}>Log out</MenuItem>
      </MenuItems>
    </Menu>
  );
}

Installation

Base UI components are all available as a single package.

npm install @base_ui/react

Once you have the package installed, import the component.

import { Menu } from '@base_ui/react/Menu';

Anatomy

Menus are implemented using a collection of related components:

<Menu.Root>
  <Menu.Trigger />
 
  <Menu.Positioner>
    <Menu.Popup>
      <Menu.Group>
        <Menu.GroupLabel />
        <Menu.Item />
        <Menu.Item />
      </Menu.Group>
 
      <Menu.Separator />
 
      <Menu.Root>
        <Menu.SubmenuTrigger />
 
        <Menu.Positioner>
          <Menu.Popup>
            <Menu.Arrow />
            <Menu.Item />
            <Menu.Item />
          </Menu.Popup>
        </Menu.Positioner>
      </Menu.Root>
    </Menu.Popup>
  </Menu.Positioner>
</Menu.Root>

Placement

By default, the menu is placed on the bottom side of its trigger, the default anchor. To change this, use the side prop:

<Menu.Root>
  <Menu.Trigger />
  <Menu.Positioner side="right">
    <Menu.Popup>
      <Menu.Item>Item 1</Menu.Item>
    </Menu.Popup>
  </Menu.Positioner>
</Menu.Root>

You can also change the alignment of the menu in relation to its anchor. By default, aligned to the leading edge of an anchor, but it can be configured otherwise using the alignment prop:

<Menu.Root>
  <Menu.Trigger />
  <Menu.Positioner side="right" alignment="end">
    <Menu.Popup>
      <Menu.Item>Item 1</Menu.Item>
    </Menu.Popup>
  </Menu.Positioner>
</Menu.Root>

Due to collision detection, the menu may change its placement to avoid overflow. Therefore, your explicitly specified side and alignment props act as "ideal", or preferred, values.

To access the true rendered values, which may change as the result of a collision, the menu element receives data attributes:

// Rendered HTML (simplified)
<div>
  <div data-side="left" data-alignment="end">
    <div>Item 1</div>
  </div>
</div>

This allows you to conditionally style the menu based on its rendered side or alignment.

Offset

The sideOffset prop creates a gap between the anchor and menu popup, while alignmentOffset slides the menu popup from its alignment, acting logically for start and end alignments.

<Menu.Positioner sideOffset={10} alignmentOffset={10}>

Orientation

By default, menus are vertical, so the up/down arrow keys navigate through options and left/right keys open and close submenus. You can change this with the orientation prop"

<Menu.Root orientation="horizontal">
  <Menu.Trigger />
  <Menu.Positioner>
    <Menu.Popup>
      <Menu.Item>Item 1</Menu.Item>
    </Menu.Popup>
  </Menu.Positioner>
</Menu.Root>

Hover

To open the Menu on hover, add the openOnHover prop:

<Menu.Root openOnHover>

By default submenus are opened on hover, but top-level menus aren't.

Delay

To change how long the menu waits until it opens or closes when openOnHover is enabled, use the delay prop, which represent how long the Menu waits after the cursor enters the trigger, in milliseconds:

<Menu.Root openOnHover delay={200}>

Radio items

Menu items can be used as radio buttons. To group them together, use the Menu.RadioGroup component:

'use client';
import * as React from 'react';
import { Menu } from '@base_ui/react/Menu';
import { styled } from '@mui/system';

export default function RadioItems() {
  return (
    <Menu.Root>
      <MenuButton>Font</MenuButton>
      <MenuPositioner alignment="start" keepMounted>
        <MenuPopup>
          <Menu.RadioGroup defaultValue={'jetbrains-mono'}>
            <RadioItem value="cascadia-code">
              <Indicator />
              Cascadia Code
            </RadioItem>
            <RadioItem value="consolas">
              <Indicator />
              Consolas
            </RadioItem>
            <RadioItem value="dejavu-sans-mono">
              <Indicator />
              DejaVu Sans Mono
            </RadioItem>
            <RadioItem value="fira-code">
              <Indicator />
              Fira Code
            </RadioItem>
            <RadioItem value="jetbrains-mono">
              <Indicator />
              JetBrains Mono
            </RadioItem>
            <RadioItem value="menlo">
              <Indicator />
              Menlo
            </RadioItem>
            <RadioItem value="monaco">
              <Indicator />
              Monaco
            </RadioItem>
            <RadioItem value="monolisa">
              <Indicator />
              Monolisa
            </RadioItem>
            <RadioItem value="source-code-pro">
              <Indicator />
              Source Code Pro
            </RadioItem>
          </Menu.RadioGroup>
        </MenuPopup>
      </MenuPositioner>
    </Menu.Root>
  );
}

const blue = {
  50: '#F0F7FF',
  100: '#C2E0FF',
  200: '#99CCF3',
  300: '#66B2FF',
  400: '#3399FF',
  500: '#007FFF',
  600: '#0072E6',
  700: '#0059B3',
  800: '#004C99',
  900: '#003A75',
};

const grey = {
  50: '#F3F6F9',
  100: '#E5EAF2',
  200: '#DAE2ED',
  300: '#C7D0DD',
  400: '#B0B8C4',
  500: '#9DA8B7',
  600: '#6B7A90',
  700: '#434D5B',
  800: '#303740',
  900: '#1C2025',
};

const MenuPopup = styled(Menu.Popup)(
  ({ theme }) => `
  font-family: 'IBM Plex Sans', sans-serif;
  font-size: 0.875rem;
  box-sizing: border-box;
  padding: 6px;
  margin: 12px 0;
  min-width: 200px;
  border-radius: 12px;
  overflow: auto;
  outline: 0;
  background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'};
  border: 1px solid ${theme.palette.mode === 'dark' ? grey[700] : grey[200]};
  color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]};
  box-shadow: 0px 4px 30px ${theme.palette.mode === 'dark' ? grey[900] : grey[200]};
  z-index: 1;
  transform-origin: var(--transform-origin);
  opacity: 0;
  transform: scale(0.8);
  transition: opacity 100ms ease-in, transform 100ms ease-in;

  &[data-menu='open'] {
    opacity: 1;
    transform: scale(1);
  }
  `,
);

const RadioItem = styled(Menu.RadioItem)(
  ({ theme }) => `
  list-style: none;
  padding: 8px;
  border-radius: 8px;
  cursor: default;
  user-select: none;

  &:last-of-type {
    border-bottom: none;
  }

  &:focus {
    outline: 3px solid ${theme.palette.mode === 'dark' ? blue[600] : blue[200]};
    background-color: ${theme.palette.mode === 'dark' ? grey[800] : grey[100]};
    color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]};
  }

  &[data-disabled] {
    color: ${theme.palette.mode === 'dark' ? grey[700] : grey[400]};
  }
  `,
);

const Indicator = styled(Menu.RadioItemIndicator)(
  ({ theme }) => `
  display: inline-block;
  width: 0.75rem;
  height: 0.75rem;
  border: 1px solid;
  vertical-align: baseline;
  margin-right: 8px;
  border-color: ${theme.palette.mode === 'dark' ? grey[800] : grey[700]};
  box-sizing: border-box;
  border-radius: 50%;

  &[data-checked] {
    background: ${theme.palette.mode === 'dark' ? grey[800] : grey[700]};
    box-shadow: 0 0 0 2px ${theme.palette.mode === 'dark' ? grey[900] : '#fff'} inset;
  }
  `,
);

const MenuButton = styled(Menu.Trigger)(
  ({ theme }) => `
  font-family: 'IBM Plex Sans', sans-serif;
  font-weight: 600;
  font-size: 0.875rem;
  line-height: 1.5;
  padding: 8px 16px;
  border-radius: 8px;
  color: white;
  transition: all 150ms ease;
  cursor: pointer;
  background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'};
  border: 1px solid ${theme.palette.mode === 'dark' ? grey[700] : grey[200]};
  color: ${theme.palette.mode === 'dark' ? grey[200] : grey[900]};
  box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);

  &:hover {
    background: ${theme.palette.mode === 'dark' ? grey[800] : grey[50]};
    border-color: ${theme.palette.mode === 'dark' ? grey[600] : grey[300]};
  }

  &:active {
    background: ${theme.palette.mode === 'dark' ? grey[700] : grey[100]};
  }

  &:focus-visible {
    box-shadow: 0 0 0 4px ${theme.palette.mode === 'dark' ? blue[300] : blue[200]};
    outline: none;
  }
  `,
);

const MenuPositioner = styled(Menu.Positioner)`
  &:focus-visible {
    outline: 0;
  }

  &[data-menu='closed'] {
    pointer-events: none;
  }
`;

If you rely on the RadioItem to manage its state (e.g., you use the defaultChecked and onCheckedChange props), ensure that the item is not unmounted when its parent menu is closed. Unmounting the component resets its state.

To do this, add the keepMounted prop to the Menu.Positioner the checkbox item is in (and all parent positioners, in the case of a nested menu):

<Menu.Root>
  <Menu.Positioner keepMounted>
    <Menu.Popup>
      <Menu.RadioGroup>
        <Menu.RadioItem defaultChecked>
          <Menu.RadioItemIndicator /> Light
        </Menu.RadioItem>
        <Menu.RadioItem>
          <Menu.RadioItemIndicator /> Dark
        </Menu.RadioItem>
      </Menu.RadioGroup>
    </Menu.Popup>
  </Menu.Positioner>
</Menu.Root>

If you keep the state externally (and use the checked prop), this isn't required.

Checkbox items

Menu items can act as checkboxes.

'use client';

import * as React from 'react';
import { Menu } from '@base_ui/react/Menu';
import { styled } from '@mui/system';

export default function CheckboxItems() {
  const createHandleMenuClick = (menuItem: string) => {
    return () => {
      console.log(`Clicked on ${menuItem}`);
    };
  };

  return (
    <Menu.Root>
      <MenuButton>My account</MenuButton>
      <MenuPositioner alignment="start" keepMounted>
        <MenuPopup>
          <MenuItem onClick={createHandleMenuClick('Profile')}>Profile</MenuItem>
          <MenuItem onClick={createHandleMenuClick('Language settings')}>
            Language settings
          </MenuItem>
          <CheckboxItem>
            <Indicator />
            Mute notifications
          </CheckboxItem>
          <CheckboxItem defaultChecked>
            <Indicator />
            Enable preview features
          </CheckboxItem>
          <MenuItem onClick={createHandleMenuClick('Log out')}>Log out</MenuItem>
        </MenuPopup>
      </MenuPositioner>
    </Menu.Root>
  );
}

const blue = {
  50: '#F0F7FF',
  100: '#C2E0FF',
  200: '#99CCF3',
  300: '#66B2FF',
  400: '#3399FF',
  500: '#007FFF',
  600: '#0072E6',
  700: '#0059B3',
  800: '#004C99',
  900: '#003A75',
};

const grey = {
  50: '#F3F6F9',
  100: '#E5EAF2',
  200: '#DAE2ED',
  300: '#C7D0DD',
  400: '#B0B8C4',
  500: '#9DA8B7',
  600: '#6B7A90',
  700: '#434D5B',
  800: '#303740',
  900: '#1C2025',
};

const MenuPopup = styled(Menu.Popup)(
  ({ theme }) => `
  font-family: 'IBM Plex Sans', sans-serif;
  font-size: 0.875rem;
  box-sizing: border-box;
  padding: 6px;
  margin: 12px 0;
  min-width: 200px;
  border-radius: 12px;
  overflow: auto;
  outline: 0;
  background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'};
  border: 1px solid ${theme.palette.mode === 'dark' ? grey[700] : grey[200]};
  color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]};
  box-shadow: 0px 4px 30px ${theme.palette.mode === 'dark' ? grey[900] : grey[200]};
  z-index: 1;
  transform-origin: var(--transform-origin);
  opacity: 0;
  transform: scale(0.8);
  transition: opacity 100ms ease-in, transform 100ms ease-in;

  &[data-menu='open'] {
    opacity: 1;
    transform: scale(1);
  }
  `,
);

const MenuItem = styled(Menu.Item)(
  ({ theme }) => `
  list-style: none;
  padding: 8px;
  border-radius: 8px;
  cursor: default;
  user-select: none;

  &:last-of-type {
    border-bottom: none;
  }

  &:focus {
    outline: 3px solid ${theme.palette.mode === 'dark' ? blue[600] : blue[200]};
    background-color: ${theme.palette.mode === 'dark' ? grey[800] : grey[100]};
    color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]};
  }

  &.[data-disabled] {
    color: ${theme.palette.mode === 'dark' ? grey[700] : grey[400]};
  }
  `,
);

const CheckboxItem = styled(Menu.CheckboxItem)(
  ({ theme }) => `
  list-style: none;
  padding: 8px;
  border-radius: 8px;
  cursor: default;
  user-select: none;

  &:last-of-type {
    border-bottom: none;
  }

  &:focus {
    outline: 3px solid ${theme.palette.mode === 'dark' ? blue[600] : blue[200]};
    background-color: ${theme.palette.mode === 'dark' ? grey[800] : grey[100]};
    color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]};
  }

  &[data-disabled] {
    color: ${theme.palette.mode === 'dark' ? grey[700] : grey[400]};
  }
  `,
);

const Indicator = styled(Menu.CheckboxItemIndicator)(
  ({ theme }) => `
  display: inline-block;
  width: 0.75rem;
  height: 0.75rem;
  border: 1px solid;
  vertical-align: baseline;
  margin-right: 8px;
  border-color: ${theme.palette.mode === 'dark' ? grey[800] : grey[700]};
  box-sizing: border-box;
  border-radius: 2px;


  &[data-checked] {
    background: ${theme.palette.mode === 'dark' ? grey[800] : grey[700]};
    box-shadow: 0 0 0 2px ${theme.palette.mode === 'dark' ? grey[900] : '#fff'} inset;
  }
  `,
);

const MenuButton = styled(Menu.Trigger)(
  ({ theme }) => `
  font-family: 'IBM Plex Sans', sans-serif;
  font-weight: 600;
  font-size: 0.875rem;
  line-height: 1.5;
  padding: 8px 16px;
  border-radius: 8px;
  color: white;
  transition: all 150ms ease;
  cursor: pointer;
  background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'};
  border: 1px solid ${theme.palette.mode === 'dark' ? grey[700] : grey[200]};
  color: ${theme.palette.mode === 'dark' ? grey[200] : grey[900]};
  box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);

  &:hover {
    background: ${theme.palette.mode === 'dark' ? grey[800] : grey[50]};
    border-color: ${theme.palette.mode === 'dark' ? grey[600] : grey[300]};
  }

  &:active {
    background: ${theme.palette.mode === 'dark' ? grey[700] : grey[100]};
  }

  &:focus-visible {
    box-shadow: 0 0 0 4px ${theme.palette.mode === 'dark' ? blue[300] : blue[200]};
    outline: none;
  }
  `,
);

const MenuPositioner = styled(Menu.Positioner)`
  &:focus-visible {
    outline: 0;
  }

  &[data-menu='closed'] {
    pointer-events: none;
  }
`;

If you rely on the CheckboxItem to manage its state (e.g., you use the defaultChecked and onCheckedChange props), ensure that the item is not unmounted when its parent menu is closed. Unmounting the component resets its state.

To do this, add the keepMounted prop to the Menu.Positioner the checkbox item is in (and all parent positioners, in the case of a nested menu):

<Menu.Root>
  <Menu.Positioner keepMounted>
    <Menu.Popup>
      <Menu.CheckboxItem defaultChecked>
        <Menu.CheckboxItemIndicator /> Item 1
      </Menu.CheckboxItem>
    </Menu.Popup>
  </Menu.Positioner>
</Menu.Root>

If you keep the state externally (and use the checked prop), this isn't required.

Nested menu

Menu items can open submenus. To make this happen, place the <Menu.Root> with all its required children where a submenu trigger has to be placed, but instead of <Menu.Trigger>, use <Menu.SubitemTrigger>, as on the demo below.

'use client';
import * as React from 'react';
import { Menu } from '@base_ui/react/Menu';
import { styled } from '@mui/system';

export default function NestedMenu() {
  const createHandleMenuClick = (menuItem: string) => {
    return () => {
      console.log(`Clicked on ${menuItem}`);
    };
  };

  return (
    <Menu.Root>
      <MenuButton>Format</MenuButton>
      <MenuPositioner side="bottom" alignment="start">
        <MenuPopup>
          <Menu.Root>
            <SubmenuTrigger>Text color</SubmenuTrigger>
            <MenuPositioner alignment="start" side="right">
              <MenuPopup>
                <MenuItem onClick={createHandleMenuClick('Text color/Black')}>
                  Black
                </MenuItem>
                <MenuItem onClick={createHandleMenuClick('Text color/Dark grey')}>
                  Dark grey
                </MenuItem>
                <MenuItem onClick={createHandleMenuClick('Text color/Accent')}>
                  Accent
                </MenuItem>
              </MenuPopup>
            </MenuPositioner>
          </Menu.Root>

          <Menu.Root>
            <SubmenuTrigger>Style</SubmenuTrigger>
            <MenuPositioner alignment="start" side="right">
              <MenuPopup>
                <Menu.Root>
                  <SubmenuTrigger>Heading</SubmenuTrigger>
                  <MenuPositioner alignment="start" side="right">
                    <MenuPopup>
                      <MenuItem
                        onClick={createHandleMenuClick('Style/Heading/Level 1')}
                      >
                        Level 1
                      </MenuItem>
                      <MenuItem
                        onClick={createHandleMenuClick('Style/Heading/Level 2')}
                      >
                        Level 2
                      </MenuItem>
                      <MenuItem
                        onClick={createHandleMenuClick('Style/Heading/Level 3')}
                      >
                        Level 3
                      </MenuItem>
                    </MenuPopup>
                  </MenuPositioner>
                </Menu.Root>
                <MenuItem onClick={createHandleMenuClick('Style/Paragraph')}>
                  Paragraph
                </MenuItem>
                <Menu.Root disabled>
                  <SubmenuTrigger disabled>List</SubmenuTrigger>
                  <MenuPositioner alignment="start" side="right">
                    <MenuPopup>
                      <MenuItem
                        onClick={createHandleMenuClick('Style/List/Ordered')}
                      >
                        Ordered
                      </MenuItem>
                      <MenuItem
                        onClick={createHandleMenuClick('Style/List/Unordered')}
                      >
                        Unordered
                      </MenuItem>
                    </MenuPopup>
                  </MenuPositioner>
                </Menu.Root>
              </MenuPopup>
            </MenuPositioner>
          </Menu.Root>

          <MenuItem onClick={createHandleMenuClick('Clear formatting')}>
            Clear formatting
          </MenuItem>
        </MenuPopup>
      </MenuPositioner>
    </Menu.Root>
  );
}

const blue = {
  50: '#F0F7FF',
  100: '#C2E0FF',
  200: '#99CCF3',
  300: '#66B2FF',
  400: '#3399FF',
  500: '#007FFF',
  600: '#0072E6',
  700: '#0059B3',
  800: '#004C99',
  900: '#003A75',
};

const grey = {
  50: '#F3F6F9',
  100: '#E5EAF2',
  200: '#DAE2ED',
  300: '#C7D0DD',
  400: '#B0B8C4',
  500: '#9DA8B7',
  600: '#6B7A90',
  700: '#434D5B',
  800: '#303740',
  900: '#1C2025',
};

const MenuPopup = styled(Menu.Popup)(
  ({ theme }) => `
  font-family: 'IBM Plex Sans', sans-serif;
  font-size: 0.875rem;
  box-sizing: border-box;
  padding: 6px;
  margin: 12px 0;
  min-width: 200px;
  border-radius: 12px;
  overflow: auto;
  outline: 0;
  background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'};
  border: 1px solid ${theme.palette.mode === 'dark' ? grey[700] : grey[200]};
  color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]};
  box-shadow: 0px 4px 30px ${theme.palette.mode === 'dark' ? grey[900] : grey[200]};
  z-index: 1;
  transform-origin: var(--transform-origin);
  opacity: 1;
  transform: scale(1, 1);
  transition: opacity 100ms ease-in, transform 100ms ease-in;

  @starting-style {
    & {
      opacity: 0;
      transform: scale(0.8);
    }
  }
      
  &[data-exiting] {
    opacity: 0;
    transform: scale(0.8);
    transition: opacity 200ms ease-in, transform 200ms ease-in;
  }
  `,
);

const MenuPositioner = styled(Menu.Positioner)`
  &:focus-visible {
    outline: 0;
  }

  &[data-state='closed'] {
    pointer-events: none;
  }
`;

const MenuItem = styled(Menu.Item)(
  ({ theme }) => `
  list-style: none;
  padding: 8px;
  border-radius: 8px;
  cursor: default;
  user-select: none;
  
  &:last-of-type {
    border-bottom: none;
  }
    
  &:focus,
  &:hover {
    background-color: ${theme.palette.mode === 'dark' ? grey[800] : grey[100]};
    color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]};
  }

  &:focus-visible {
    outline: none;
  }

  &[data-disabled] {
    color: ${theme.palette.mode === 'dark' ? grey[700] : grey[400]};
  }
  `,
);

const SubmenuTrigger = styled(Menu.SubmenuTrigger)(
  ({ theme }) => `
  list-style: none;
  padding: 8px;
  border-radius: 8px;
  cursor: default;
  user-select: none;

  &:last-of-type {
    border-bottom: none;
  }

  &::after {
    content: '›';
    float: right;
  }

  &[data-popup-open] {
    background-color: ${theme.palette.mode === 'dark' ? grey[900] : grey[50]};
    color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]};
  }

  &:focus,
  &:hover {
    background-color: ${theme.palette.mode === 'dark' ? grey[800] : grey[100]};
    color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]};
  }

  &:focus-visible {
    outline: none;
  }

  &[data-disabled] {
    color: ${theme.palette.mode === 'dark' ? grey[700] : grey[400]};
  }
  `,
);

const MenuButton = styled(Menu.Trigger)(
  ({ theme }) => `
  font-family: 'IBM Plex Sans', sans-serif;
  font-weight: 600;
  font-size: 0.875rem;
  line-height: 1.5;
  padding: 8px 16px;
  border-radius: 8px;
  color: white;
  transition: all 150ms ease;
  cursor: pointer;
  background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'};
  border: 1px solid ${theme.palette.mode === 'dark' ? grey[700] : grey[200]};
  color: ${theme.palette.mode === 'dark' ? grey[200] : grey[900]};
  box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);

  &:hover {
    background: ${theme.palette.mode === 'dark' ? grey[800] : grey[50]};
    border-color: ${theme.palette.mode === 'dark' ? grey[600] : grey[300]};
  }

  &:active {
    background: ${theme.palette.mode === 'dark' ? grey[700] : grey[100]};
  }

  &:focus-visible {
    box-shadow: 0 0 0 4px ${theme.palette.mode === 'dark' ? blue[300] : blue[200]};
    outline: none;
  }
  `,
);

Escape key behavior

You can control if pressing the Escape key closes just the current submenu or the whole tree. By default, the whole menu closes, but setting the closeParentOnEsc prop modifies this behavior:

<Menu.Root>
  <Menu.Trigger />
  <Menu.Positioner>
    <Menu.Popup>
      <Menu.Item>Item 1</Menu.Item>
      <Menu.Root closeParentOnEsc={false}>
        <Menu.SubmenuTrigger>Submenu</Menu.SubmenuTrigger>
 
        <Menu.Positioner>
          <Menu.Popup>
            <Menu.Item>Submenu item 1</Menu.Item>
            <Menu.Item>Submenu item 2</Menu.Item>
          </Menu.Popup>
        </Menu.Positioner>
      </Menu.Root>
    </Menu.Popup>
  </Menu.Positioner>
</Menu.Root>

Arrow

To add an arrow (caret or triangle) inside the menu popup that points toward the center of the anchor element, use the Menu.Arrow component:

<Menu.Positioner>
  <Menu.Popup>
    <Menu.Arrow />
    <Menu.Item>Item 1</Menu.Item>
    <Menu.Item>Item 2</Menu.Item>
  </Menu.Popup>
</Menu.Positioner>

It automatically positions a wrapper element that can be styled or contain a custom SVG shape.

Separator

To visually separate items, use the Menu.Separator component:

<Menu.Popup>
  <Menu.Item>Item 1</Menu.Item>
  <Menu.Separator />
  <Menu.Item>Item 2</Menu.Item>
</Menu.Popup>

The Menu.Separator is a re-exported Separator component. See the Separator docs to learn about its API.

Animations

The menu can animate when opening or closing with either:

CSS transitions

Here is an example of how to apply a symmetric scale and fade transition with the default conditionally-rendered behavior:

<Menu.Popup className="MenuPopup">
  <Menu.Item>Item 1</Menu.Item>
</Menu.Popup>
.MenuPopup {
  transform-origin: var(--transform-origin);
  transition-property: opacity, transform;
  transition-duration: 0.2s;
  /* Represents the final styles once exited */
  opacity: 0;
  transform: scale(0.9);
}
 
/* Represents the final styles once entered */
.MenuPopup[data-open] {
  opacity: 1;
  transform: scale(1);
}
 
/* Represents the initial styles when entering */
.MenuPopup[data-entering] {
  opacity: 0;
  transform: scale(0.9);
}

Styles need to be applied in three states:

In newer browsers, there is a feature called @starting-style which allows transitions to occur on open for conditionally-mounted components:

/* Base UI API - Polyfill */
.MenuPopup[data-entering] {
  opacity: 0;
  transform: scale(0.9);
}
 
/* Official Browser API - no Firefox support as of May 2024 */
@starting-style {
  .MenuPopup[data-open] {
    opacity: 0;
    transform: scale(0.9);
  }
}

CSS animations

CSS animations can also be used, requiring only two separate declarations:

@keyframes scale-in {
  from {
    opacity: 0;
    transform: scale(0.9);
  }
}
 
@keyframes scale-out {
  to {
    opacity: 0;
    transform: scale(0.9);
  }
}
 
.MenuPopup {
  animation: scale-in 0.2s forwards;
}
 
.MenuPopup[data-exiting] {
  animation: scale-out 0.2s forwards;
}

JavaScript animations

The keepMounted prop lets an external library control the mounting, for example framer-motion's AnimatePresence component.

function App() {
  const [open, setOpen] = useState(false);
  return (
    <Menu.Root open={open} onOpenChange={setOpen}>
      <Menu.Trigger>Trigger</Menu.Trigger>
      <AnimatePresence>
        {open && (
          <Menu.Positioner keepMounted>
            <Menu.Popup
              render={
                <motion.div
                  initial={{ opacity: 0 }}
                  animate={{ opacity: 1 }}
                  exit={{ opacity: 0 }}
                />
              }
            >
              <Menu.Item>Item 1</Menu.Item>
              <Menu.Item>Item 2</Menu.Item>
            </Menu.Popup>
          </Menu.Positioner>
        )}
      </AnimatePresence>
    </Menu.Root>
  );
}

Animation states

Four states are available as data attributes to animate the popup, which enables full control depending on whether the popup is being animated with CSS transitions or animations, JavaScript, or is using the keepMounted prop.

Overriding default components

Use the render prop to override the rendered elements with your own components.

// Element shorthand
<Menu.Popup render={<MyMenuPopup />} />
// Function
<Menu.Popup render={(props) => <MyMenuPopup {...props} />} />

API Reference

MenuItem

An unstyled menu item to be used within a Menu.

PropTypeDefaultDescription
closeOnClickbooltrueIf true, the menu will close when the menu item is clicked.
disabledboolfalseIf true, the menu item will be disabled.
idstringThe id of the menu item.
labelstringA text representation of the menu item's content. Used for keyboard text navigation matching.
onClickfuncThe click handler for the menu item.

MenuPositioner

Renders the element that positions the Menu popup.

PropTypeDefaultDescription
alignmentenum'center'The alignment of the Menu element to the anchor element along its cross axis.
alignmentOffsetnumber0The offset of the Menu element along its alignment axis.
anchorunionThe anchor element to which the Menu popup will be placed at.
arrowPaddingnumber5Determines the padding between the arrow and the Menu popup's edges. Useful when the popover popup has rounded corners via border-radius.
classNameunionClass names applied to the element or a function that returns them based on the component's state.
collisionBoundaryunion'clippingAncestors'The boundary that the Menu element should be constrained to.
collisionPaddingunion5The padding of the collision boundary.
containerunionThe container element to which the Menu popup will be appended to.
hideWhenDetachedboolfalseIf true, the Menu will be hidden if it is detached from its anchor element due to differing clipping contexts.
keepMountedboolfalseWhether the menu popup remains mounted in the DOM while closed.
positionMethodenum'absolute'The CSS position strategy for positioning the Menu popup element.
renderunionA function to customize rendering of the component.
sideenum'bottom'The side of the anchor element that the Menu element should align to.
sideOffsetnumber0The gap between the anchor element and the Menu element.
stickyboolfalseIf true, allow the Menu to remain in stuck view while the anchor element is scrolled out of view.

MenuPopup

PropTypeDefaultDescription
classNameunionClass names applied to the element or a function that returns them based on the component's state.
idstringThe id of the popup element.
renderunionA function to customize rendering of the component.

MenuRoot

PropTypeDefaultDescription
animatedbooltrueIf true, the Menu supports CSS-based animations and transitions. It is kept in the DOM until the animation completes.
closeParentOnEscbooltrueDetermines if pressing the Esc key closes the parent menus. This is only applicable for nested menus.
If set to false pressing Esc closes only the current menu.
defaultOpenboolfalseIf true, the Menu is initially open.
delaynumber100The delay in milliseconds until the menu popup is opened when openOnHover is true.
direnum'ltr'The direction of the Menu (left-to-right or right-to-left).
disabledboolfalseIf true, the Menu is disabled.
loopbooltrueIf true, using keyboard navigation will wrap focus to the other end of the list once the end is reached.
onOpenChangefuncCallback fired when the component requests to be opened or closed.
openboolAllows to control whether the dropdown is open. This is a controlled counterpart of defaultOpen.
openOnHoverboolWhether the menu popup opens when the trigger is hovered after the provided delay. By default, openOnHover is set to true for nested menus.
orientationenum'vertical'The orientation of the Menu (horizontal or vertical).

MenuTrigger

PropTypeDefaultDescription
classNameunionClass names applied to the element or a function that returns them based on the component's state.
disabledboolfalseIf true, the component is disabled.
focusableWhenDisabledboolfalseIf true, allows a disabled button to receive focus.
labelstringLabel of the button
renderunionA function to customize rendering of the component.

SubmenuTrigger

PropTypeDefaultDescription
classNameunionClass names applied to the element or a function that returns them based on the component's state.
disabledboolfalseIf true, the menu item will be disabled.
labelstringA text representation of the menu item's content. Used for keyboard text navigation matching.
renderunionA function to customize rendering of the component.

MenuArrow

Renders an arrow that points to the center of the anchor element.

PropTypeDefaultDescription
classNameunionClass names applied to the element or a function that returns them based on the component's state.
hideWhenUncenteredboolfalseIf true, the arrow is hidden when it can't point to the center of the anchor element.
renderunionA function to customize rendering of the component.

MenuRadioGroup

PropTypeDefaultDescription
childrennodeThe content of the component.
classNameunionClass names applied to the element or a function that returns them based on the component's state.
defaultValueanyThe default value of the selected radio button. This is the uncontrolled equivalent of value.
onValueChangefunc() => {}Function called when the selected value changes.
renderunionA function to customize rendering of the component.
valueanyThe value of the selected radio button.

MenuRadioItem

An unstyled radio menu item to be used within a Menu.

PropTypeDefaultDescription
valueanyValue of the radio item. This is the value that will be set in the MenuRadioGroup when the item is selected.
closeOnClickbooltrueIf true, the menu will close when the menu item is clicked.
disabledboolfalseIf true, the menu item will be disabled.
idstringThe id of the menu item.
labelstringA text representation of the menu item's content. Used for keyboard text navigation matching.
onClickfuncThe click handler for the menu item.

MenuRadioItemIndicator

PropTypeDefaultDescription
classNameunionClass names applied to the element or a function that returns them based on the component's state.
keepMountedbooltrueIf true, the component is mounted even if the Radio is not checked.
renderunionA function to customize rendering of the component.

MenuCheckboxItem

An unstyled checkbox menu item to be used within a Menu.

PropTypeDefaultDescription
closeOnClickbooltrueIf true, the menu will close when the menu item is clicked.
disabledboolfalseIf true, the menu item will be disabled.
idstringThe id of the menu item.
labelstringA text representation of the menu item's content. Used for keyboard text navigation matching.
onClickfuncThe click handler for the menu item.

MenuCheckboxItemIndicator

PropTypeDefaultDescription
classNameunionClass names applied to the element or a function that returns them based on the component's state.
keepMountedbooltrueIf true, the component is mounted even if the checkbox is not checked.
renderunionA function to customize rendering of the component.

MenuGroup

PropTypeDefaultDescription
childrennodeThe content of the component.
classNameunionClass names applied to the element or a function that returns them based on the component's state.
renderunionA function to customize rendering of the component.

MenuGroupLabel

PropTypeDefaultDescription
classNameunionClass names applied to the element or a function that returns them based on the component's state.
renderunionA function to customize rendering of the component.

Contents