SolutionsPackagesComponentsCLI — Coming soonResourcesDocumentationStorybookChangelogBlogAboutSign up freeGet early access

Documentation

Dropdown Menu

A menu of actions opened from a button, with roving keyboard focus, typeahead, icons, shortcut hints, separators, and destructive items.

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

PropTypeDefaultDescription
itemsDropdownMenuEntry[][]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.
openbooleanfalseWhether the menu is open. Reflected. Setting it to true opens the menu and focuses the first item.
labelstring''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

SlotDescription
triggerThe button that opens the menu, usually a dsgn-button. Gets aria-haspopup and aria-expanded.

CSS parts

PartDescription
menuThe floating role="menu" panel.
itemEach role="menuitem" row.
item-iconAn item’s leading icon.
item-shortcutAn item’s trailing shortcut hint.
separatorA divider between groups of items.

Events

EventDetailDescription
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

MethodDescription
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.

KeyOn the triggerIn the menu
Enter / SpaceOpen 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.
TabMove 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" and aria-expanded. On a dsgn-button they land on its inner <button>. The menu is role="menu", each item role="menuitem", disabled items aria-disabled="true", and separators role="separator".
  • An icon-only trigger has no visible text, so give it an aria-label that names its object — “Actions for Priya Natarajan”, not just “Actions” — so a list of identical ”⋯” buttons can be told apart.
  • aria-controls is 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 treats aria-controls as optional. For the same reason the menu is named with aria-label (from label, or copied from the trigger) rather than aria-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.
© 2026 LightLeap Labs LLC. All rights reserved. · Terms of Service · Privacy Policy · Cookies · Contact · Support