Dropdown Menu
A dropdown menu triggered by a button that reveals a list of actions or options. This component combines a toggle button with a menu panel, providing a compact way to present multiple actions or choices without taking up permanent screen space. Common use cases include action menus, settings menus, context-sensitive options, and navigation.
The component manages its own focus lifecycle: when the menu opens, focus moves automatically to the first menu item. Arrow key navigation cycles through items with wrapping, and Escape closes the menu. The trigger button communicates its state to assistive technology via aria-haspopup and aria-expanded.
Example
Show demo markup
<div class="dropdown-menu" role="menu"><div role="menuitem">Option 1</div><div role="menuitem">Option 2</div></div>
Usage example
<DropdownMenu label="Options" bind:open>
<li role="menuitem" tabindex="-1">Edit</li>
<li role="menuitem" tabindex="-1">Duplicate</li>
<li role="menuitem" tabindex="-1">Delete</li>
</DropdownMenu>
Show Svelte source
// In your Svelte component:
import DropdownMenu from "lily-design-system-svelte-headless/components/DropdownMenu/DropdownMenu.svelte";
<DropdownMenu>
<!-- list children -->
</DropdownMenu>
Usage
"More actions" dropdown on a card component:
<DropdownMenu label="More actions" open={menuOpen}>
<MenuItem onclick={() => handleEdit()}>Edit</MenuItem>
<MenuItem onclick={() => handleDuplicate()}>Duplicate</MenuItem>
<MenuItem onclick={() => handleDelete()}>Delete</MenuItem>
</DropdownMenu>
Settings dropdown with dynamic items:
<DropdownMenu label="Settings" open={settingsOpen}>
{#each settingsOptions as option}
<MenuItem onclick={() => handleOption(option)}>
{option.label}
</MenuItem>
{/each}
</DropdownMenu>
Props
label: string (required) -- accessible name for the menu button, also displayed as button text
open: boolean (default: false) -- whether the menu is visible; bindable
children: slot (required) -- menu items, each should have role="menuitem" and tabindex="-1"
Keyboard Interactions
- Enter or Space (on button): toggles the menu open/closed
- ArrowDown: moves focus to the next menu item, wraps from last to first
- ArrowUp: moves focus to the previous menu item, wraps from first to last
- Home: moves focus to the first menu item
- End: moves focus to the last menu item
- Escape: closes the menu
ARIA
aria-haspopup="true" -- on the trigger button, indicates it opens a menu
aria-expanded="{open}" -- on the trigger button, communicates whether the menu is currently shown
aria-label="{label}" -- on both the trigger button and the menu, provides an accessible name
role="menu" -- on the menu container, identifies it as a menu widget
role="menuitem" -- expected on each child item provided by the consumer
When to Use
- Use for a menu that opens below a trigger button, such as a "More actions" button.
- Use to present a list of actions or options when screen space is limited.
- Use for settings menus, action menus, or context-sensitive options on cards and rows.
- Use when the menu should appear on click rather than right-click.
When Not to Use
- Do not use for form selections -- use Select or Combobox instead.
- Do not use for navigation -- use NavigationMenu instead.
- Do not use when the menu should appear on right-click -- use ContextMenu instead.
Headless
This headless component provides a trigger <button> with aria-haspopup and aria-expanded, a <div> menu container with role="menu", automatic focus management (first item on open), full arrow key navigation with wrapping, Home/End support, and Escape dismissal. The consumer provides all visual styling, positioning, animation, and menu item content.
Styles
The consumer provides all CSS styling. The component renders with a .dropdown-menu class for targeting. No default styles are included — this is a fully headless component.
Testing
- Verify the component renders a
<div> element with class dropdown-menu
- Verify aria-label="{label}"` -- on both the trigger button and the menu, provides an accessible name
- Verify role="menu"` -- on the menu container, identifies it as a menu widget
- Verify role="menuitem"` -- expected on each child item provided by the consumer
- Verify keyboard interactions work correctly
- Verify pass-through attributes are applied
Advice
- Designers: Position the menu directly below the trigger button. Keep the menu width consistent and avoid overly long item labels. Use separators for grouping related actions.
- Developers: Ensure each child element has
role="menuitem" and tabindex="-1". Return focus to the trigger button after the menu closes.
Implementation Notes
- Renders a wrapper
<div> containing a <button> trigger and a conditionally rendered menu <div>
- Supports two-way binding on the
open prop with the parent
- Uses reactive state to track the
menuRef DOM reference for querying menu items
- Uses
$effect() to auto-focus the first [role="menuitem"] element when the menu opens
- Menu items are discovered dynamically via
querySelectorAll("[role='menuitem']")
- ArrowDown/ArrowUp navigation wraps around from last to first and first to last
- Home and End keys jump to first and last menu items respectively
- Escape key closes the menu
- The trigger button displays the
label text and uses it as aria-label
- Spreads
restProps onto the outer wrapper <div>
Related components
menu-item — one item in a menu
References
- WAI-ARIA Menu Button Pattern: https://www.w3.org/WAI/ARIA/apg/patterns/menubutton/
- WAI-ARIA menu role: https://www.w3.org/TR/wai-aria-1.2/#menu
- WAI-ARIA menuitem role: https://www.w3.org/TR/wai-aria-1.2/#menuitem
Canonical contract
Generated from this component's canonical metadata — the machine-checked source the implementations are held to.
Metadata
- Component: dropdown-menu
- PascalCase: DropdownMenu
- Description: a menu that opens below a trigger button
- Status: stable — exercised in composed page flows under e2e and axe, beyond the per-component checks
- HTML tag: <div>
- CSS class: .dropdown-menu
- Interactive: yes
ARIA
aria-haspopup="true" -- on the trigger button, indicates it opens a menu aria-expanded="{open}" -- on the trigger button, communicates whether the menu is currently shown aria-label="{label}" -- on both the trigger button and the menu, provides an accessible name role="menu" -- on the menu container, identifies it as a menu widget role="menuitem" -- expected on each child item provided by the consumer
Keyboard
- Enter or Space (on button): toggles the menu open/closed
- ArrowDown: moves focus to the next menu item, wraps from last to first
- ArrowUp: moves focus to the previous menu item, wraps from first to last
- Home: moves focus to the first menu item
- End: moves focus to the last menu item
- Escape: closes the menu
Props
label: string (required) -- accessible name for the menu button, also displayed as button text open: boolean (default: false) -- whether the menu is visible; bindable children: slot (required) -- menu items, each should have role="menuitem" and tabindex="-1"