artui
Components

DropdownMenu

View source

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-menu

Playground

Try Down / Up, typeahead, Home / End, Escape, and Arrow Right on Preferences.

disabled item

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

PropTypeDefaultDescription
openbooleannoneWhen provided, puts the root into controlled mode. Must be paired with onOpenChange.
onOpenChange(open: boolean) => voidnoneCalled when the menu open state changes. Required when open is provided.
childrenrequiredReactNodenoneMust contain exactly one Trigger and one Content. Other sub-components go inside Content.

Requires exactly one of children, aria-label, or aria-labelledby.

PropTypeDefaultDescription
childrenAccessibleTextnoneVisible label text. Exactly one of children, aria-label, or aria-labelledby is required: compile error otherwise.
aria-labelAccessibleTextnoneInvisible label for icon-only triggers. Mutually exclusive with children and aria-labelledby.
aria-labelledbystringnoneID of an external label element. Mutually exclusive with children and aria-label.
disabledbooleannoneDisables the trigger button.
classNamestringnoneAdditional CSS class applied to the trigger button.
PropTypeDefaultDescription
childrenrequiredReactNodenoneMenu items, separators, and Sub groups. Empty content triggers a dev overlay.
classNamestringnoneAdditional CSS class applied to the menu panel.
PropTypeDefaultDescription
onSelectrequired() => voidnoneCalled when the item is activated by click, Enter, or Space.
disabledbooleannoneMarks the item as aria-disabled and prevents selection.
classNamestringnoneAdditional CSS class applied to the item element.
PropTypeDefaultDescription
classNamestringnoneAdditional CSS class applied to the separator element.

Same accessible-name contract as Trigger.

PropTypeDefaultDescription
childrenAccessibleTextnoneVisible label for the sub-trigger. One of children, aria-label, or aria-labelledby is required.
aria-labelAccessibleTextnoneInvisible label for the sub-trigger.
aria-labelledbystringnoneExternal label element ID for the sub-trigger.
disabledbooleannoneDisables the sub-trigger.
PropTypeDefaultDescription
childrenrequiredReactNodenoneItems inside the sub-menu.
classNamestringnoneAdditional CSS class applied to the sub-menu panel.

Keyboard

Root menu

KeyAction
EnterOpen menu and focus first item
SpaceOpen menu and focus first item
Arrow DownFocus next item
Arrow UpFocus previous item
HomeFocus first item
EndFocus last item
EscapeClose menu, return focus to trigger
TabClose menu, move focus naturally
A-ZTypeahead: jump to first item starting with that character
KeyAction
Arrow RightOpen submenu from SubTrigger
Arrow LeftClose submenu, return focus to SubTrigger
EscapeClose submenu, return focus to SubTrigger

The trigger position is computed once when the menu opens. Any scroll or resize event closes the menu.

Accessibility

CriterionNameHow the component satisfies it
1.3.1(A)Info and RelationshipsThe 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)KeyboardFull 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 OrderFocus 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 VisibleFocus 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, ValueTrigger 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.

On this page