DropdownMenu
Accessible dropdown menu following the WAI-ARIA APG Menu Button pattern. Supports single-level submenus, typeahead, full keyboard navigation, and compile-time enforcement of accessible names.
npx artui@latest add dropdown-menuPlayground
Try Down / Up, typeahead, Home / End, Escape, and Arrow Right on Preferences.
Usage
Basic
import { DropdownMenu } from '@/components/dropdown-menu';
<DropdownMenu>
<DropdownMenu.Trigger>Account</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item onSelect={() => navigate('/profile')}>Profile</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => navigate('/settings')}>Settings</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={signOut}>Sign out</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>With submenu
<DropdownMenu>
<DropdownMenu.Trigger>Account</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item onSelect={() => navigate('/profile')}>Profile</DropdownMenu.Item>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Preferences</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.Item onSelect={() => setTheme('light')}>Light theme</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => setTheme('dark')}>Dark theme</DropdownMenu.Item>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Content>
</DropdownMenu>Arrow Right or click opens the submenu. Arrow Left or Escape closes it and returns focus to the SubTrigger.
Controlled
const [open, setOpen] = useState(false);
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenu.Trigger>Account</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item onSelect={() => navigate('/profile')}>Profile</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>Icon-only trigger
<DropdownMenu>
<DropdownMenu.Trigger aria-label="User menu">
<UserIcon aria-hidden="true" />
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item onSelect={signOut}>Sign out</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>Disabled items
<DropdownMenu>
<DropdownMenu.Trigger>Actions</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item onSelect={publish}>Publish</DropdownMenu.Item>
<DropdownMenu.Item onSelect={archive} disabled>Archive (unavailable)</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>Disabled items get aria-disabled="true" and keyboard navigation skips them.
API
DropdownMenu (root)
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | none | When provided, puts the root into controlled mode. Must be paired with onOpenChange. |
| onOpenChange | (open: boolean) => void | none | Called when the menu open state changes. Required when open is provided. |
| childrenrequired | ReactNode | none | Must contain exactly one Trigger and one Content. Other sub-components go inside Content. |
DropdownMenu.Trigger
Requires exactly one of children, aria-label, or aria-labelledby.
| Prop | Type | Default | Description |
|---|---|---|---|
| children | AccessibleText | none | Visible label text. Exactly one of children, aria-label, or aria-labelledby is required: compile error otherwise. |
| aria-label | AccessibleText | none | Invisible label for icon-only triggers. Mutually exclusive with children and aria-labelledby. |
| aria-labelledby | string | none | ID of an external label element. Mutually exclusive with children and aria-label. |
| disabled | boolean | none | Disables the trigger button. |
| className | string | none | Additional CSS class applied to the trigger button. |
DropdownMenu.Content
| Prop | Type | Default | Description |
|---|---|---|---|
| childrenrequired | ReactNode | none | Menu items, separators, and Sub groups. Empty content triggers a dev overlay. |
| className | string | none | Additional CSS class applied to the menu panel. |
DropdownMenu.Item
| Prop | Type | Default | Description |
|---|---|---|---|
| onSelectrequired | () => void | none | Called when the item is activated by click, Enter, or Space. |
| disabled | boolean | none | Marks the item as aria-disabled and prevents selection. |
| className | string | none | Additional CSS class applied to the item element. |
DropdownMenu.Separator
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | none | Additional CSS class applied to the separator element. |
DropdownMenu.SubTrigger
Same accessible-name contract as Trigger.
| Prop | Type | Default | Description |
|---|---|---|---|
| children | AccessibleText | none | Visible label for the sub-trigger. One of children, aria-label, or aria-labelledby is required. |
| aria-label | AccessibleText | none | Invisible label for the sub-trigger. |
| aria-labelledby | string | none | External label element ID for the sub-trigger. |
| disabled | boolean | none | Disables the sub-trigger. |
DropdownMenu.SubContent
| Prop | Type | Default | Description |
|---|---|---|---|
| childrenrequired | ReactNode | none | Items inside the sub-menu. |
| className | string | none | Additional CSS class applied to the sub-menu panel. |
Keyboard
Root menu
| Key | Action |
|---|---|
| Enter | Open menu and focus first item |
| Space | Open menu and focus first item |
| Arrow Down | Focus next item |
| Arrow Up | Focus previous item |
| Home | Focus first item |
| End | Focus last item |
| Escape | Close menu, return focus to trigger |
| Tab | Close menu, move focus naturally |
| A-Z | Typeahead: jump to first item starting with that character |
Submenu
| Key | Action |
|---|---|
| Arrow Right | Open submenu from SubTrigger |
| Arrow Left | Close submenu, return focus to SubTrigger |
| Escape | Close submenu, return focus to SubTrigger |
The trigger position is computed once when the menu opens. Any scroll or resize event closes the menu.
Accessibility
| Criterion | Name | How the component satisfies it |
|---|---|---|
| 1.3.1(A) | Info and Relationships | The menu panel has role=menu; each item has role=menuitem; the separator has role=separator. A dev overlay fires when Content renders with no children. |
| 2.1.1(A) | Keyboard | Full APG keyboard contract: Arrow Down/Up to navigate items, Home/End for first/last, Enter/Space to select, Escape to close, typeahead to jump by first character. ArrowRight opens a sub-menu; ArrowLeft or Escape closes it. |
| 2.4.3(A) | Focus Order | Focus moves to the first menu item on open. On close via Escape, focus returns to the trigger. Sub-menus return focus to the sub-trigger on close. |
| 2.4.7(AA) | Focus Visible | Focus indicators use outline (not box-shadow) to remain visible in Windows High Contrast Mode. |
| 2.5.5(AAA) | Target Size (Enhanced) | The trigger button has a minimum 44×44 px touch target. |
| 4.1.2(A) | Name, Role, Value | Trigger carries aria-haspopup=menu, aria-expanded, and aria-controls. The menu carries aria-labelledby pointing at the trigger. Sub-triggers carry the same trio for their sub-menus. Accessible names on Trigger and SubTrigger are enforced at compile time via AccessibleNameProps. |
Do
Always provide an accessible name on the trigger
<DropdownMenu.Trigger>Account</DropdownMenu.Trigger>
<DropdownMenu.Trigger aria-label="User menu">{undefined}</DropdownMenu.Trigger>Always provide onSelect on every Item
<DropdownMenu.Item onSelect={() => navigate('/profile')}>Profile</DropdownMenu.Item>Use disabled items to signal unavailable actions
<DropdownMenu.Item onSelect={archive} disabled>Archive (unavailable)</DropdownMenu.Item>Don't
Render a trigger with no accessible name
<DropdownMenu.Trigger /> {/* compile error */}
<DropdownMenu.Trigger>{""}</DropdownMenu.Trigger> {/* compile error */}Both are TypeScript compile errors.
Omit onSelect
<DropdownMenu.Item>Profile</DropdownMenu.Item> {/* compile error */}onSelect is required. An item without an action has no purpose in a menu.
Render an empty Content
<DropdownMenu.Content>{false}</DropdownMenu.Content>This fires a dev overlay with WCAG 1.3.1.
Dialog
Accessible modal dialog using native <dialog> with showModal(). Browser-managed focus trap, top layer, and Escape handling.
Select
An accessible select with two modes. Pick one option from a styled native select, or several from a chip-based listbox. Both sit on real HTML elements, so forms and screen readers work without extra wiring.