Skip to content
Overlays

Dropdown Menu

A menu triggered by a button that displays a list of actions or options. Supports grouping, labels, and separators.

Grouped Menu

Import

import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@mikenotthepope/substrateui"

Portal Container

The popup portals into <body> unless container names another element. On <body> it sits outside every landmark, which axe reports under region. Pass an element inside <main> and the open menu stays in it.

Composition

DropdownMenu
├── DropdownMenuTrigger
└── DropdownMenuContent
    ├── DropdownMenuLabel
    ├── DropdownMenuSeparator
    └── DropdownMenuItem

Direction

The submenu indicator is a ChevronRight, and it mirrors: a submenu opens toward the end of the line, which in RTL is leftward. The RTL icon audit classifies it flip in RTL.

What does not mirror is a shortcut hint. ⌘K is a key name, not a direction, so DropdownMenuShortcut moves to the start edge in RTL but its text stays as written.

Accessibility

While a menu with modal={false} is open, axe reports aria-hidden-focuson six empty spans. A modal menu has the same spans, but axe reads its backdrop as a modal and files them under needs review. They are Base UI's focus guards: <span aria-hidden="true" tabindex="0" data-base-ui-focus-guard> around the trigger and the popup. A guard takes Tab, closes the menu and passes focus straight on to the next control on the page. Focus never rests on one.

The rule is wrong about them. axe accepts an aria-hidden element that redirects focus when it carries an onfocus handler, but it reads the DOM property, and React attaches the handler at the root, so axe finds none. No prop removes the guards. Without tabindex, Tab walks past them and leaves the menu open behind it; without aria-hidden, screen readers announce empty spans. Scope the rule to them in your axe run instead:

await new AxeBuilder({ page })
  .exclude("[data-base-ui-focus-guard]")
  .analyze()

API Reference

PropTypeDefaultDescription
openboolean—Controlled open state of the dropdown menu.
onOpenChange(open: boolean) => void—Callback fired when the open state changes.

DropdownMenuContent

PropTypeDefaultDescription
containerHTMLElement | ShadowRoot | RefObject | nulldocument.bodyElement the popup portals into. Pass one inside <main> to keep the open menu inside a landmark. Submenus follow it.
positionMethod"absolute" | "fixed""absolute"CSS position the popup is placed with. Match the trigger: pass "fixed" when the trigger is position: fixed.