Skip to content
Folio

Overlays

DropdownMenu

A menu of actions.

Usage

When to reach for it

Actions. Items that navigate belong in a nav; items that set a value are a Select or a RadioGroup.
TSX
import { DropdownMenu } from '@misoto22/design'

Notes

Radix DropdownMenu root + trigger, re-exported as typed passthroughs.

Anatomy

DropdownMenu anatomy
ElementDescription
TriggerrequiredA passthrough to Radix, which renders its own bare button unless you pass asChild. It carries aria-haspopup and data-state, which is what lets a Button show that its menu is open.
PanelrequiredThe portalled menu: at least 11rem wide, 6px off the trigger, colliding with 8px of padding against the viewport or the OverlayContainer frame. It has no max height, so it flips rather than scrolls.
ItemA row. icon takes either spelling — the icon component, sized to 16px here, or a rendered element, placed as given; destructive paints the row --danger; disabled drops pointer events and the opacity.
LabelA mono eyebrow, on its own. Visual only — Radix renders it as a plain div, and the arrow keys skip it. DropdownMenuGroup is the one that heads rows: it renders role="group" and points its aria-labelledby at this.
GroupA named section: role="group" around the rows, with label rendered inside it as the Label and named through aria-labelledby. The wiring is the component’s job because a caller doing it by hand has to invent an id.
SeparatorA hairline between groups, and a real role="separator" — which a menu permits, unlike the listbox a Command palette is built on.

Best practices

Do

  • Pass icon the component — icon={Settings} — and let the row size it, so a menu of ten rows draws ten icons at one size rather than ten sizes. The element spelling is accepted too, and used to be the exact opposite of what CommandItem took one import away.
  • Give the trigger asChild and a real Button: without it Radix renders its own unstyled button, and the menu ends up hanging off a control that is not part of the system’s set.
  • Hold a dialog’s open state yourself and call event.preventDefault() in the item’s onSelect — selecting a row closes the menu, and Radix’s close moves focus back to the trigger, which arrives after the dialog has claimed it and pulls the reader straight back out.
  • Stop at about a dozen rows. There is no max height on the panel, so a longer menu grows until it hits the collision padding and flips above the trigger — SearchableMenu is the same list once it has outgrown this one.

Don’t

  • Radix defaults modal to true and nothing here overrides it, so while the menu is open the page behind is scroll-locked and its pointer events are off — a menu is not the place for something the reader is meant to consult the page while using.
  • A bare DropdownMenuLabel over rows is a picture of a heading: Radix’s MenuLabel is a plain div with no role and nothing tying it to what follows, so the sections a sighted reader sees arrive as one undivided list. DropdownMenuGroup renders both halves and wires them together.
  • There is no checkbox item, radio item or submenu in this package’s exports — a menu that needs a checked state has to import from @radix-ui/react-dropdown-menu directly, and that row arrives with none of this file’s styling on it.

Examples

an account menu

A menu of ACTIONS, which is the only thing it is for: rows that navigate belong in a nav, and rows that set a value are a Select or a RadioGroup. Two details are easy to get wrong. icon takes the icon component itself — icon={RiSettings3Line}, never icon={<RiSettings3Line />} — which is the exact reverse of CommandItem one import away. And the trigger needs asChild with a real Button, or Radix renders an unstyled button of its own and the menu hangs off a control that is not part of the system. The label is a plain div with no role: it groups rows for the eye, and a screen reader walking the menu by role never hears it.

an overflow button

The overflow control at the end of a row, where a word would only repeat what the row already says. The trigger is an iconOnly Button, so aria-label is not optional — without it the control is announced as an unnamed button. align is end because the panel is at least 11rem wide and would otherwise open back across its own trigger; it collides with 8px of padding against the frame and flips rather than scrolling, since the panel has no maximum height. A row that is not available right now is disabled rather than missing, so the list does not change shape between visits.

kyoto-february.tif

where it opens

Two menus at the two ends of a toolbar, each aligned to the edge it sits on. align decides which side of the trigger the panel lines up with, and the panel is at least 11rem wide — so a menu at the end edge left on the default center opens back across its own trigger and then gets shoved by the 8px collision padding. side flips the whole panel above when there is no room below, which is also what happens to a long menu: there is no maximum height here, so it grows until it collides. Past about a dozen rows the list has stopped being scannable, and the answer is a SearchableMenu — the same rows with a filter over them — not a submenu.

Parts

Composed at the call site rather than configured through props, so a layout this component did not anticipate is still expressible.

DropdownMenuContent

A menu surface.

A menu is a list of ACTIONS. If the items navigate somewhere, they belong in a nav; if they set a value, that is a Select or a RadioGroup — this package ships no checkbox or radio menu item, and a plain item pretending to be a choice loses the checked state a screen reader needs.

Also accepts everything in ComponentProps<typeof DropdownMenuPrimitive.Content>. Those are forwarded to the underlying element and are not listed row by row.

DropdownMenuItem

A menu row.

Highlight is driven by Radix's data-highlighted, which covers both pointer hover and keyboard focus — styling :hover alone leaves the keyboard user unable to see where they are.

DropdownMenuItem props
PropTypeDefaultDescription
destructivebooleanfalsePaints the row as destructive. Use for delete, revoke, disconnect.
iconRemixiconComponentType | ReactNodeOptional leading icon, rendered before the label. Either spelling: `icon={Settings}` passes the component and this sizes it, `icon={<Settings size={16} />}` passes the element and this places it. The two used to mean opposite things one import apart — `CommandItem.icon` took the element while this took the component — and the wrong one did not fail a type check into anything actionable, it failed at render.

Also accepts everything in ComponentProps<typeof DropdownMenuPrimitive.Item>. Those are forwarded to the underlying element and are not listed row by row.

DropdownMenuSeparator

Hairline divider between menu groups.

Takes no props of its own.

DropdownMenuLabel

Mono eyebrow heading, on its own.

Visual only: Radix renders it as a bare <div> with no role, so it segments the menu for a reader who can see it and for nobody else. Reach for DropdownMenuGroup when the eyebrow is a HEADING over rows; this is right for a line that heads nothing — the signed-in address at the top of an account menu.

Takes no props of its own.

DropdownMenuGroup

A named section of a menu.

The eyebrow alone was a picture of a heading. Radix's MenuLabel carries no role and no aria-labelledby wiring, and MenuGroup — which does carry role="group" — was not re-exported by this package at all, so a sighted reader saw three labelled sections and a screen-reader user got one undifferentiated list. This renders the group, renders the label inside it, and points the one at the other, which is the whole of the fix and not something a caller should have to remember.

DropdownMenuGroup props
PropTypeDefaultDescription
labelReactNodeThe eyebrow over the rows, and the group's accessible name.

Also accepts everything in ComponentProps<typeof DropdownMenuPrimitive.Group>. Those are forwarded to the underlying element and are not listed row by row.

Re-exports

DropdownMenu = DropdownMenuPrimitive.Root

Radix DropdownMenu root + trigger, re-exported as typed passthroughs.

DropdownMenuTrigger = DropdownMenuPrimitive.Trigger

Keyboard

DropdownMenu keyboard interactions
KeyDoes
EnterSpaceOpens the menu and lands on the first item.
Moves between items.
a–zJumps to the next item starting with that letter.
EscapeCloses the menu and returns focus to the trigger.

Accessibility

  • Inside a bounded frame — a device preview, an embedded console — wrap the subtree in `<OverlayContainer container={el}>`. The panel then renders into that element and collides with its edges instead of the viewport’s, and inherits the `dir` and `data-density` set there.
  • Highlight is driven by data-highlighted, which covers hover AND keyboard focus — styling :hover alone leaves the keyboard user unable to see where they are.
  • DropdownMenuGroup segments the menu for a screen reader as well as for the eye; a bare Label does it for the eye alone.