What Dropdown does for you, and what it leaves to the page around it.
What the menu announces
The menu is fully described for assistive technology. The trigger is not, and that part is up to you.
The list is a real menu: role="menu" on the list and role="menuitem" on each row, with aria-disabled where it applies.
{ type: "divider" } renders role="separator", so it is announced as a break and not read as an empty item.
{ type: "group" } renders role="group" around its children. Its title is a heading, never a command.
A submenu row carries aria-haspopup, aria-expanded and aria-controls, so nesting is announced for you.
The trigger gets no ARIA. Dropdown adds no aria-haspopup or aria-expanded to your child, so write them yourself, as the next section shows.
The trigger is yours
Whatever you pass as children is the trigger, and it keeps its own semantics.
Pass a real <button>. A <span>, a bare string or an <a> with no href cannot be focused or pressed from the keyboard, so the menu becomes pointer-only.
Add aria-haspopup="menu" and aria-expanded yourself.aria-expanded needs the open state, so control open with onOpenChange.
Name an icon-only trigger. Give an overflow icon, an avatar or a chevron an aria-label that says what the menu is for (“Row actions”, “Account menu”). In a table, name the row: “Actions for Grace Hopper”.
Name a split button's icon-only half on its own: “More save actions” reads better than “More”.
trigger={["contextMenu"]} has no keyboard equivalent. Offer the same commands from a visible button as well.
Keyboard
While the menu is open, Escape and Tab work wherever focus is. Inside the list, the arrow keys move between items.
Opening is the trigger's job. With trigger={["click"]} on a real button, Enter and Space open it. A hover trigger has no key that opens it.
Tab moves focus into the menu the first time it is pressed. Press it again and the menu closes and focus returns to the trigger.
Up and Down move between items, and Home and End jump to the ends.
Enter or Right opens a submenu, and Left or Escape goes back to its parent. Under direction="rtl" Left opens and Right goes back.
Escape closes the menu and returns focus to the trigger, at every depth.
Focus and the portal
The menu renders at the end of document.body, far from its trigger in the DOM. Focus is the only link between the two.
Focus stays on the trigger when the menu opens. Tab is the way in. Set autoFocus to move focus into the menu, which suits a menu opened by a keyboard shortcut.
Focus returns to the trigger on Escape and on the closing Tab, but not after an item click. If the action replaces what was on screen, move focus yourself.
A reader moving through the page reaches the menu only through its trigger, which is one more reason the trigger must be focusable.
getPopupContainer changes where the menu is rendered. It changes nothing about roles or focus.
Disabled and selected items
A disabled item gets aria-disabled and the arrow keys skip it. It is still read aloud, so its label should say why it is unavailable.
menu.selectable does not announce the selection. The highlight is only a color. Put the current value in the trigger's text, or use Select.
danger is only a color. Say “Delete” in the label so the meaning does not rest on red.
extra is read as part of the row. Keep it short (“Ctrl K”) and do not repeat the label.
Contrast and motion
Check item text against the menu's own background, not the page's. A menu over a dark header is still a light menu.
Hover and the keyboard-active row share one fill. Check that it stands out from a plain row at your smallest text size.
Disabled and danger rows rely on text color alone. Check their contrast when you change the palette.
The submenu arrow uses the quiet colorIcon. If you restyle it, keep it distinct from the label.
The open and close motion is decoration. Put any motion you add behind motion-safe:, and turn it all off with the theme's motion token.