Open this component in Storybook
The <dsgn-dropdown-menu> component opens a menu of actions from a
trigger button — ”⋯ → Remove member” — following the WAI-ARIA
menu button pattern.
Activating an item fires dsgn-select and closes the menu.
It is not a form control and has no value. To pick a value in a form, use
<dsgn-select>. For a panel of site navigation links,
use the <dsgn-nav-link> dropdown, which is a link
disclosure rather than a menu.
Submenus, checkbox and radio items, context (right-click) menus, and a bottom-sheet presentation on phones are not supported yet.
Usage
<dsgn-dropdown-menu>
<dsgn-button slot="trigger" appearance="outline">Actions</dsgn-button>
</dsgn-dropdown-menu>
import '@ds-gn/ui/dropdown-menu';
import '@ds-gn/ui/button';
import { Icon } from '@ds-gn/ui/icon';
import * as icons from '@ds-gn/ui/icon/icons';
// Items name their icons, so register the ones you use.
Icon.registerIcons(icons);
const menu = document.querySelector('dsgn-dropdown-menu');
menu.items = [
{ value: 'view', label: 'View profile', icon: 'user' },
{ value: 'copy-email', label: 'Copy email', icon: 'copy', shortcut: '⌘C' },
{ value: 'resend-invite', label: 'Resend invite', disabled: true },
{ separator: true },
{
value: 'remove',
label: 'Remove member',
icon: 'trash2',
variant: 'danger',
},
];
menu.addEventListener('dsgn-select', (event) => run(event.detail.value));
In plain HTML, items can also be set as a JSON attribute:
<dsgn-dropdown-menu
items='[{"value":"rename","label":"Rename"},{"value":"archive","label":"Archive"}]'
>
<dsgn-button slot="trigger">Actions</dsgn-button>
</dsgn-dropdown-menu>
Playground
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | DropdownMenuEntry[] | [] | Items and separators, in order. A property, or a JSON attribute in plain HTML. See Items. |
| size | 'sm' | 'md' | 'lg' | 'md' | Item size on the shared scale: 28, 36, and 44px rows. On a coarse (touch) pointer items are always lg. |
| align | 'start' | 'end' | 'start' | The trigger edge the menu lines up with. Use end for icon-only triggers at the end of a row. |
| open | boolean | false | Whether the menu is open. Reflected. Setting it to true opens the menu and focuses the first item. |
| label | string | '' | Accessible name for the menu. Defaults to the trigger’s aria-label, or its text. |
| surface | 'light' | 'dark' | 'light' | Semantic token surface to use. |
Items
type DropdownMenuEntry = DropdownMenuItem | { separator: true };
interface DropdownMenuItem {
value: string; // reported in dsgn-select
label: string; // visible text, accessible name, and typeahead target
icon?: IconName; // leading icon, by registered name
shortcut?: string; // trailing hint such as "⌘C" — display only
disabled?: boolean; // focusable, but cannot be activated
variant?: 'default' | 'danger'; // danger for destructive actions
}
The types are exported from @ds-gn/ui/dropdown-menu/props. Icons are drawn
by the menu from the shared registry (see Icon), so they
must be registered with Icon.registerIcons(); an unregistered name renders
no icon. The menu does not bind the keys in shortcut — handle those in your
app.
Slots
| Slot | Description |
|---|---|
| trigger | The button that opens the menu, usually a dsgn-button. Gets aria-haspopup and aria-expanded. |
CSS parts
| Part | Description |
|---|---|
menu | The floating role="menu" panel. |
item | Each role="menuitem" row. |
item-icon | An item’s leading icon. |
item-shortcut | An item’s trailing shortcut hint. |
separator | A divider between groups of items. |
Events
| Event | Detail | Description |
|---|---|---|
dsgn-select | { value } | Fired when an enabled item is activated, just before the menu closes and focus returns to the trigger. Not fired for disabled items. Bubbles and crosses the shadow boundary. |
In React, the generated wrapper exposes this as onDsgnSelect:
import { Button, DropdownMenu } from '@ds-gn/ui/react';
<DropdownMenu items={items} onDsgnSelect={(e) => run(e.detail.value)}>
<Button slot="trigger">Actions</Button>
</DropdownMenu>;
The event is dsgn-select rather than select because the native select
event (text selection in inputs) also bubbles, so a listener for one would
catch the other. Every DS-GN event is dsgn- prefixed for the same reason.
Methods
| Method | Description |
|---|---|
show(focus = 'first') | Open the menu and focus its 'first' or 'last' item. |
hide() | Close the menu without moving focus. |
Keyboard
Focus moves into the menu: the focused item is the highlighted one.
| Key | On the trigger | In the menu |
|---|---|---|
Enter / Space | Open and focus the first item. | Activate the focused item (not a disabled one), close, focus trigger. |
↓ | Open and focus the first item. | Next item; wraps from the last to the first. |
↑ | Open and focus the last item. | Previous item; wraps from the first to the last. |
Home / End | — | First / last item. |
| A character | — | Next item starting with it. Characters typed within 500ms combine. |
Escape | — | Close and return focus to the trigger. |
Tab | Move focus on. | Close and let focus move on. Focus is not trapped. |
Separators are skipped. Disabled items can be focused, so they can be discovered, but not activated.
The pointer works the same way: clicking the trigger opens the menu on its first item and clicking it again closes it; hovering an item focuses it, so hover and keyboard highlight look the same; clicking outside closes the menu without moving focus. Only one menu is open at a time on the page.
Positioning
The menu opens in the browser’s top layer (a popover="manual" element), so
an ancestor’s overflow or stacking context cannot clip or cover it —
including inside a <dsgn-modal>, where Escape closes the
menu first and the dialog on the next press.
It hangs below the trigger, at least 232px wide and wider if its items need
it. With align="start" it lines up with the trigger’s start edge; with
align="end" with its end edge. When there is no room below it flips above,
and it shifts to keep a 16px viewport gutter. It follows the trigger on scroll
and resize while open. In a browser without the Popover API it falls back to
absolute positioning inside the component.
Examples
Icon-only row triggers
The dashboard pattern: a ”⋯” button at the end of each row, end-aligned. Name each trigger after its object.
<dsgn-dropdown-menu align="end">
<dsgn-button
slot="trigger"
appearance="ghost"
icon-name="ellipsis"
icon-position="only"
aria-label="Actions for Priya Natarajan"
></dsgn-button>
</dsgn-dropdown-menu>
Sizes
<dsgn-dropdown-menu size="sm">…</dsgn-dropdown-menu>
<dsgn-dropdown-menu size="md">…</dsgn-dropdown-menu>
<dsgn-dropdown-menu size="lg">…</dsgn-dropdown-menu>
On touch screens (pointer: coarse) the menu uses lg regardless, for 44px
touch targets.
Dark surface
<dsgn-dropdown-menu surface="dark">
<dsgn-button slot="trigger" surface="dark">Actions</dsgn-button>
</dsgn-dropdown-menu>
Accessibility
- The trigger gets
aria-haspopup="menu"andaria-expanded. On adsgn-buttonthey land on its inner<button>. The menu isrole="menu", each itemrole="menuitem", disabled itemsaria-disabled="true", and separatorsrole="separator". - An icon-only trigger has no visible text, so give it an
aria-labelthat names its object — “Actions for Priya Natarajan”, not just “Actions” — so a list of identical ”⋯” buttons can be told apart. aria-controlsis omitted. The trigger is in your markup and the menu in the component’s shadow root, and an ID reference cannot cross that boundary; the APG pattern treatsaria-controlsas optional. For the same reason the menu is named witharia-label(fromlabel, or copied from the trigger) rather thanaria-labelledby.- Destructive items use the danger colour and a danger fill when focused, and should say what they do (“Remove member”), so they do not rely on colour.
- Opening and closing animate with a short scale and fade, and not at all under
prefers-reduced-motion: reduce.