DropdownMenu
Keyboard-navigable action menu anchored to a trigger.
import { DropdownMenu, DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuItem } from '@elirobinson/react/components/organisms/DropdownMenu';Styles: @elirobinson/react/styles/organisms/DropdownMenu.css — already included when you import @elirobinson/react/styles.css.
Show code
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@elirobinson/react/components/organisms/DropdownMenu';
export default function Basic() {
return (
<DropdownMenu>
<DropdownMenuTrigger className="ds-button ds-button--secondary">
Guide actions
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>U8 Soccer Season Plan</DropdownMenuLabel>
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Duplicate</DropdownMenuItem>
<DropdownMenuItem>Archive</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}When to use it
Use DropdownMenu for a set of actions anchored to a trigger — row actions in a table, a "…"
overflow menu, a view switcher. It's action-oriented: items are role="menuitem" and run a
handler, they don't navigate. If every item should actually change the URL, use NavigationMenu
or plain links instead — a role="menuitem" on a link tells assistive tech the wrong thing
about what activating it does.
Aligning items with inset
inset on DropdownMenuLabel/DropdownMenuItem adds left padding so plain text lines up with
sibling items that have a leading marker — a checkmark on the current selection, in this case.
Show code
import { useState } from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@elirobinson/react/components/organisms/DropdownMenu';
const views = ['List', 'Grid', 'Board'] as const;
export default function Alignment() {
const [view, setView] = useState<(typeof views)[number]>('List');
return (
<DropdownMenu>
<DropdownMenuTrigger className="ds-button ds-button--secondary">
View: {view}
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel inset>Layout</DropdownMenuLabel>
{views.map((option) => (
<DropdownMenuItem key={option} inset={option !== view} onClick={() => setView(option)}>
{option === view ? `✓ ${option}` : option}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}Submitting a form from an item
An item that submits a form — a sign-out button running a server action, say — must not close
the menu as part of the click that submits it. Closing is a discrete state update React flushes
synchronously, still inside the click dispatch: the portal unmounts before the browser reaches
the click's default action, React has already suppressed the browser's own submission so that it
can run the form's action itself, and no live fiber is left to run it against. The result is an
item that looks like it does nothing at all.
So DropdownMenuItem reads its own type: an item with type="submit" does not close on
select, and everything else still does.
<DropdownMenuContent>
<form action={signOutAction}>
<DropdownMenuItem type="submit">Sign out</DropdownMenuItem>
</form>
</DropdownMenuContent>The menu then stays open until the action navigates away, or until you close it yourself once
the action resolves. closeOnSelect overrides the default in either direction —
closeOnSelect={false} keeps an ordinary item's menu open (a toggle the user may want to flip
more than once), and closeOnSelect on a submit item closes it anyway, which is only correct
when the submission does not need the form to survive the click.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
defaultOpen | boolean | false | — |
onOpenChange | ((open: boolean) => void) | — | — |
open | boolean | — | — |
DropdownMenuContent
| Prop | Type | Default | Description |
|---|---|---|---|
align | "start" | "center" | "end" | — | `'start'` lines the panel's left edge up with the trigger's and gives it the trigger's width as a minimum — the menu/listbox shape. `'end'` is the same shape mirrored: the panel's *right* edge is pinned to the trigger's, which is what a trigger near the right edge of the viewport needs. `'center'` centres it on the trigger and leaves the width to the content, which is what a tooltip wants. Default `'start'`. |
side | "top" | "bottom" | — | Which edge of the trigger the panel hangs from. Default `'bottom'`. |
Also accepts all HTMLAttributes<HTMLDivElement> & Pick<AnchoredOverlayContentProps, 'side' | 'align'> props.
DropdownMenuLabel
| Prop | Type | Default | Description |
|---|---|---|---|
inset | boolean | — | — |
Also accepts all HTMLAttributes<HTMLDivElement> props.
DropdownMenuSeparator
No props of its own beyond the inherited HTML attributes.
Also accepts all HTMLAttributes<HTMLDivElement> props.
DropdownMenuTrigger
No props of its own beyond the inherited HTML attributes.
Also accepts all ButtonHTMLAttributes<HTMLButtonElement> props.
DropdownMenuItem
| Prop | Type | Default | Description |
|---|---|---|---|
closeOnSelect | boolean | — | Whether choosing this item closes the menu. Defaults to `true` for an ordinary item, and to `false` when `type="submit"` — closing unmounts the portal during the click, which cancels the form submission the click was for. Set it explicitly to override either default. |
inset | boolean | — | — |
Also accepts all ButtonHTMLAttributes<HTMLButtonElement> props.
Accessibility
DropdownMenuContentrendersrole="menu", portaled todocument.body; eachDropdownMenuItemisrole="menuitem". The trigger carriesaria-haspopup="menu",aria-expanded, andaria-controls.Escapecloses the menu from anywhere on the page — a document-level listener, active only while the menu is open. Clicking anywhere outside the trigger or the menu content also closes it.- Opening the menu does not move focus into it — clicking or activating the trigger leaves DOM focus on the trigger button itself. Arrow-key/Home/End navigation only takes over once a menu item already has focus (for example, after tabbing to one).
- Keyboard, once a menu item has focus:
ArrowDown/ArrowUpmove focus between items and wrap around at both ends (ArrowDownpast the last item goes to the first, and vice versa — unlike Combobox/CommandPalette's clamped, non-wrapping model, since this menu moves real DOM focus rather than tracking anaria-activedescendant).Home/Endjump to the first/last item.Tabcloses the menu and lets focus continue to whatever's next in the page, rather than trapping it. DropdownMenuSeparatorrendersrole="separator"witharia-orientation="horizontal".- Content is positioned with fixed coordinates computed from the trigger's bounding rect, recalculated on scroll and resize while the menu is open, so the menu follows its trigger.
- A menu that doesn't fit below its trigger flips above it, and one that overruns the right
edge pins its right edge to the trigger's instead — the
bottom/startyou get by default are preferences, not guarantees. Each axis flips at most once per open and never flips back, so a menu can't oscillate while you scroll.sideandalignonDropdownMenuContentset the preferred side. - A menu that fits on neither side is shifted rather than shrunk: it keeps its width and
slides along the axis until it is back inside the viewport, so its items never reflow into
whatever space is left beside a pinned edge. It stops being edge-aligned with its trigger,
which is the price. The width it keeps has a floor —
.ds-dropdown__contentsets--anchored-min-width: 180px, and the positioner keeps the larger of that and the trigger's width. A menu taller than the viewport is the one case no offset rescues: it takes amax-heightof the viewport and scrolls its own items. SeeuseAnchoredPosition.
Do
- Group related items under a DropdownMenuLabel and separate groups with DropdownMenuSeparator.
- Keep item labels as short verb phrases — the menu is for actions, not longform text.
- Give the trigger real content (icon and/or label) — aria-haspopup and aria-expanded are already wired.
- Test with Tab specifically — since initial focus stays on the trigger, keyboard flows depend on Tab order reaching an item.
Don't
- Put a link where an action belongs — items are buttons that run a handler, not anchors that navigate.
- Rely on hover to open it — activation is click/keyboard only, with no hover-intent timer.
- Put a destructive action next to routine ones without a separator.
- Assume the first item gets focus automatically on open — it doesn’t.