Radix Dropdown Menu for Row Actions
Permalink to "Radix Dropdown Menu for Row Actions"Radix UI’s DropdownMenu implements the ARIA menu button pattern: a trigger with aria-haspopup="menu" and aria-expanded, a menu with role="menu", arrow-key navigation, typeahead, Escape to close and focus returned to the trigger. In data tables, it is a natural choice for per-row action menus (“…” buttons). The library handles behaviour; the accessibility work is naming triggers per row, choosing item types correctly, handling destructive actions, and telling users what happened after an action.
This guide belongs to headless UI primitives for data.
Spec reference
Permalink to "Spec reference"- Menu button pattern — trigger button with
aria-haspopup="menu"andaria-expanded;role="menu"withmenuitem,menuitemcheckbox,menuitemradio. - Radix DropdownMenu —
Root,Trigger,Portal,Content,Item,CheckboxItem,RadioGroup,Separator,Sub; keyboard: arrows, Home/End, typeahead, Escape, Enter/Space. - Focus — focus moves into the menu on open and returns to the trigger on close.
- Relevant criteria: SC 4.1.2, 2.1.1, 2.4.3 (A), SC 4.1.3 (AA) for outcomes.
When to use — and when not to
Permalink to "When to use — and when not to"Use DropdownMenu for row actions with three or more options. For one or two actions, visible buttons in the row are simpler and faster. For actions that apply to many rows, use a bulk toolbar instead.
Do not put form inputs or complex content in a menu; use a popover or dialog. Do not trigger menus only on right-click; ContextMenu should have a keyboard equivalent (Shift+F10 or the visible trigger).
The misapplication: every row’s trigger named “More actions”, producing dozens of identically named buttons in the tab order.
Annotated code example
Permalink to "Annotated code example"import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
function RowActions({ invoice, onArchive, onDuplicate }) {
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button type="button" className="icon-btn" aria-label={`Actions for ${invoice.id}`}> {/* SC 4.1.2 */}
<DotsIcon aria-hidden="true" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content align="end" sideOffset={4}> {/* labelled by the trigger */}
<DropdownMenu.Item onSelect={() => openInvoice(invoice)}>Open</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => onDuplicate(invoice)}>Duplicate</DropdownMenu.Item>
<DropdownMenu.CheckboxItem checked={invoice.flagged} onCheckedChange={(v) => flag(invoice, v)}>
Flag for review {/* menuitemcheckbox with state */}
</DropdownMenu.CheckboxItem>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={() => confirmArchive(invoice)}>Archive…</DropdownMenu.Item> {/* ellipsis: opens a dialog */}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Key | Behaviour | Expected announcement |
|---|---|---|
| Enter / Space / Down on trigger | Open; focus first item | “Actions for INV-1042, menu, Open, 1 of 4” |
| Up / Down | Move | Item names |
| Typing | Typeahead | Matched item |
| Enter / Space on item | Activate; close | Outcome via status |
| Space on checkbox item | Toggle | “Flag for review, checked” |
| Escape | Close; focus to trigger | Trigger name |
| Tab | Close and move on | Next element |
Integration context
Permalink to "Integration context"The menu button pattern itself is covered in row action menus with the menu button pattern. Destructive actions follow confirming and undoing destructive row actions. Portalling and focus order are discussed in anchor positioning and focus order for overlays.
Gotchas
Permalink to "Gotchas"Triggers inside grids. In a role="grid", the trigger should not be an extra tab stop; manage it within the grid’s focus model (Enter on the cell, or tabIndex={-1} with grid navigation).
Rows removed by actions. Archiving removes the row and its trigger; Radix returns focus to a trigger that no longer exists. Move focus explicitly to the next row.
Portals. DropdownMenu.Portal renders at the end of the body; Radix manages focus return, but reading order differs. Test with screen readers in browse mode.
Worked example
Permalink to "Worked example"An admin table had 50 rows, each with a “More actions” trigger. Screen reader users navigating by buttons heard “More actions” 50 times. The team named triggers “Actions for {record}”, used CheckboxItem for “Flag for review”, added an ellipsis and confirmation dialog for Archive, moved focus to the next row after archiving and announced “INV-1042 archived. Undo available.” Voice control users could now say “click actions for INV-1042”.
Testing it with a screen reader
Permalink to "Testing it with a screen reader"- Navigate by buttons (B in NVDA); confirm each trigger is named with its record.
- Open a menu with Enter; confirm the menu name and first item.
- Use arrows and typeahead; confirm item names and checkbox states.
- Activate an item that removes the row; confirm focus lands on the next row and the outcome is announced.
- Press Escape; confirm focus returns to the trigger.
Wrapping it for product teams
Permalink to "Wrapping it for product teams"A RowActions wrapper takes recordName and an actions array with kinds (action, toggle, destructive). It renders correctly named triggers, uses CheckboxItem for toggles, routes destructive actions through a confirmation dialog, announces outcomes and moves focus when rows disappear. Teams declare actions; the wrapper handles accessibility.
Common mistakes
Permalink to "Common mistakes"- Generic trigger names.
- Using Item for toggles instead of CheckboxItem.
- No outcome announcements.
- Focus lost when the action removes the row.
Adoption checklist
Permalink to "Adoption checklist"- Wrap DropdownMenu in a
RowActionscomponent requiring a record name. - Provide item helpers for actions, toggles and destructive actions (with confirmation).
- Connect outcomes to the shared announcer.
- Handle focus after row removal centrally.
- Test inside grids with roving focus.
FAQ
Permalink to "FAQ"Does Radix DropdownMenu follow the ARIA menu pattern?
Yes; it implements the menu button pattern including keyboard handling and focus management.
Should menus be portalled?
Radix portals by default and manages focus. If reading order matters for your users, render inline where possible.
How do I show keyboard shortcuts in items?
Render the shortcut text visually and add aria-keyshortcuts to the item if the shortcut works globally.
Can I use ContextMenu for right-click?
Yes, but also provide the visible trigger and Shift+F10 support so keyboard users can reach the same actions.
How do I name the menu?
Radix labels the content by the trigger; a trigger named with the record names the menu too.
How do I handle disabled actions?
Use the disabled prop; disabled items are skipped or announced as unavailable. Explain why in the label if it helps.
Should destructive items be red?
Colour can help, but use text (“Archive…”, “Delete invoice…”) and confirmations; never colour alone.
Should every row have a menu trigger in the tab order?
In a semantic table, yes, each trigger is a tab stop; name them well. In an interactive grid, keep triggers inside the grid’s focus model instead.
How do I add submenus accessibly?
Radix Sub components open with Right arrow and close with Left; keep nesting to one level and name submenu triggers clearly (“Move to…”).
Related
Permalink to "Related"- Row action menus with the menu button pattern — the pattern
- Confirming and undoing destructive actions — destructive items
- Headless UI Combobox for filters — another primitive