Base components
Dropdown menu
A trigger that opens a menu of actions and options, where the panel grows out of the trigger and its rows fade in just after it, and the highlight follows the pointer and the arrow keys.
Live preview
Loading preview
An account menu on an avatar trigger with a header (name and email), items View profile, Settings, Keyboard shortcuts (with shortcuts), a theme submenu with radio items Light, Dark and System, a Compact mode switch item, and a footer button Log out; next to it a More actions icon menu and a right-click context menu area. Replay opens the account menu, opens the theme submenu, then closes both.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add dropdown-menuUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { Avatar, AvatarLabelGroup } from '../avatar/avatar';
import { Button } from '../button/button';
import {
DropdownMenu,
DropdownMenuButtonItem,
DropdownMenuContext,
DropdownMenuDivider,
DropdownMenuFooter,
DropdownMenuHeader,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSubmenu,
DropdownMenuSwitchItem,
} from './dropdown-menu';
import { dropdownMenuParts } from './dropdown-menu-variants';
const ICONS = {
user: 'M8 8a2.667 2.667 0 1 0 0-5.333A2.667 2.667 0 0 0 8 8Zm-5.333 5.333c0-2.209 2.387-4 5.333-4s5.333 1.791 5.333 4',
settings:
'M2 4.667h7.333M12.667 4.667H14M2 11.333h2M7.333 11.333H14M11 6.333a1.667 1.667 0 1 0 0-3.333 1.667 1.667 0 0 0 0 3.333ZM5.667 13a1.667 1.667 0 1 0 0-3.333 1.667 1.667 0 0 0 0 3.333Z',
keyboard:
'M2 4.667c0-.737.597-1.334 1.333-1.334h9.334c.736 0 1.333.597 1.333 1.334v6.666c0 .737-.597 1.334-1.333 1.334H3.333A1.333 1.333 0 0 1 2 11.333V4.667ZM4.667 6.667h.006M7.333 6.667h.007M10 6.667h.007M5.333 9.667h5.334',
theme: 'M13.333 9.08A5.333 5.333 0 1 1 6.92 2.667a4.148 4.148 0 0 0 6.413 6.413Z',
compact: 'M2.667 4h10.666M2.667 8h10.666M2.667 12h10.666',
copy: 'M5.333 5.333v-2c0-.736.597-1.333 1.334-1.333h6c.736 0 1.333.597 1.333 1.333v6c0 .737-.597 1.334-1.333 1.334h-2M3.333 5.333h6c.737 0 1.334.597 1.334 1.334v6c0 .736-.597 1.333-1.334 1.333h-6A1.333 1.333 0 0 1 2 12.667v-6c0-.737.597-1.334 1.333-1.334Z',
edit: 'M9.333 3.333 12.667 6.667M2 14l2.833-.333c.288-.034.432-.05.566-.094.12-.04.233-.094.338-.165.117-.08.22-.182.424-.387L14 5.167a1.65 1.65 0 0 0-2.333-2.334L3.82 10.68c-.205.205-.307.307-.387.424a1.333 1.333 0 0 0-.165.338c-.044.134-.06.278-.094.566L2 14Z',
trash:
'M2 4h12M6 4V2.667h4V4M12.667 4l-.48 8.16a1.333 1.333 0 0 1-1.333 1.307H5.146a1.333 1.333 0 0 1-1.333-1.307L3.333 4',
} as const;
function Icon({ name }: { name: keyof typeof ICONS }) {
return (
<svg
aria-hidden="true"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d={ICONS[name]} />
</svg>
);
}
function LogOutIcon() {
return (
<svg
aria-hidden="true"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.67"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M13.333 14.167 17.5 10l-4.167-4.167M17.5 10h-10M7.5 17.5H4.167A1.667 1.667 0 0 1 2.5 15.833V4.167A1.667 1.667 0 0 1 4.167 2.5H7.5" />
</svg>
);
}
function MoreIcon() {
return (
<svg aria-hidden="true" viewBox="0 0 20 20" fill="currentColor">
<circle cx="10" cy="4.5" r="1.25" />
<circle cx="10" cy="10" r="1.25" />
<circle cx="10" cy="15.5" r="1.25" />
</svg>
);
}
const CONTEXT_ITEMS = (
<>
<DropdownMenuItem icon={<Icon name="copy" />} shortcut="⌘C" keyShortcuts="Meta+C">
Copy
</DropdownMenuItem>
<DropdownMenuItem icon={<Icon name="edit" />}>Rename</DropdownMenuItem>
<DropdownMenuDivider />
<DropdownMenuItem icon={<Icon name="trash" />}>Delete</DropdownMenuItem>
</>
);
export default function DropdownMenuDemo({ replay = 0 }: { replay?: number }) {
const [open, setOpen] = React.useState(false);
const [themeOpen, setThemeOpen] = React.useState(false);
const [theme, setTheme] = React.useState('system');
React.useEffect(() => {
if (replay === 0) return;
const timers = [
window.setTimeout(() => setOpen(true), 0),
window.setTimeout(() => setThemeOpen(true), 700),
window.setTimeout(() => setThemeOpen(false), 2000),
window.setTimeout(() => setOpen(false), 2300),
];
return () => {
for (const timer of timers) window.clearTimeout(timer);
};
}, [replay]);
return (
<div className="flex w-full max-w-xl flex-col items-center gap-8">
<div className="flex items-center gap-4">
<DropdownMenu
aria-label="Account menu"
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setThemeOpen(false);
}}
trigger={
<button type="button" aria-label="Account menu" className={dropdownMenuParts.avatarTrigger}>
<Avatar size="sm" name="Olivia Rhye" status="online" />
</button>
}
header={
<DropdownMenuHeader>
<AvatarLabelGroup
size="md"
name="Olivia Rhye"
supportingText="olivia@example.com"
status="online"
/>
</DropdownMenuHeader>
}
footer={
<DropdownMenuFooter>
<DropdownMenuButtonItem icon={<LogOutIcon />}>Log out</DropdownMenuButtonItem>
</DropdownMenuFooter>
}
>
<DropdownMenuItem icon={<Icon name="user" />} shortcut="⌘K→P">
View profile
</DropdownMenuItem>
<DropdownMenuItem icon={<Icon name="settings" />} shortcut="⌘S" keyShortcuts="Meta+S">
Settings
</DropdownMenuItem>
<DropdownMenuItem icon={<Icon name="keyboard" />} shortcut="?">
Keyboard shortcuts
</DropdownMenuItem>
<DropdownMenuDivider />
<DropdownMenuSubmenu
label="Theme"
icon={<Icon name="theme" />}
open={themeOpen}
onOpenChange={setThemeOpen}
>
<DropdownMenuRadioGroup value={theme} onValueChange={setTheme}>
<DropdownMenuRadioItem value="light">Light</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="dark">Dark</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="system">System</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubmenu>
<DropdownMenuSwitchItem icon={<Icon name="compact" />}>Compact mode</DropdownMenuSwitchItem>
</DropdownMenu>
<DropdownMenu
aria-label="More actions"
trigger={
<Button size="sm" hierarchy="secondary" iconOnly aria-label="More actions">
<MoreIcon />
</Button>
}
>
{CONTEXT_ITEMS}
</DropdownMenu>
</div>
<DropdownMenuContext
aria-label="File actions"
trigger={
<div
// biome-ignore lint/a11y/noNoninteractiveTabindex: focus lets the menu key open the context menu.
tabIndex={0}
className="flex h-28 w-full max-w-sm items-center justify-center rounded-xl border border-dashed border-primary text-sm text-tertiary outline-none focus-visible:shadow-focus-ring"
>
Right-click in this area
</div>
}
>
{CONTEXT_ITEMS}
</DropdownMenuContext>
</div>
);
}
API
Parts: trigger (button, icon button, avatar or account card), trigger chevron, panel (positioner and popup), header (account, subheading, search field or avatar group), item: leading slot (icon, avatar, dot, check or spacer), text, shortcut, submenu chevron, checkbox item, radio item, switch item (trailing control), divider, submenu panel, footer (button or version text), scroll bar.
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | undefined | Controlled open state. |
| defaultOpen | boolean | false | Open state when uncontrolled. |
| side | "bottom" | "top" | "left" | "right" | "bottom" | Side of the trigger the panel opens on. It flips when there is no room. |
| align | "start" | "center" | "end" | "end" | Panel alignment to the trigger. Figma aligns to the right edge. |
| sideOffset | number | 4 | Gap between the trigger and the panel, in px. |
| width | number | 240 | Panel width in px (Figma 216 to 248). |
| maxHeight | number | undefined | Panel height before the list scrolls; the scroll bar shows when the list is longer. |
| modal | boolean | true | Blocks page scroll and outside pointer events while open. |
| disabled | boolean | false | Turns off the trigger. |
| Event | Description |
|---|---|
| openChange | The menu opened or closed. Payload: the new open state. |
| select | On DropdownMenuItem: the item was chosen with a click, Enter or Space. The menu closes unless the handler prevents it. |
| checkedChange | On DropdownMenuCheckboxItem and DropdownMenuSwitchItem: the item was turned on or off. Payload: the new state. |
| valueChange | On DropdownMenuRadioGroup: a radio item was chosen. Payload: its value. |
| Slot | Description |
|---|---|
| trigger | The trigger element. Any Lunia button, an avatar or an account card. |
| default | Menu content: DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup with DropdownMenuRadioItem, DropdownMenuSwitchItem, DropdownMenuSubmenu, DropdownMenuDivider. |
| header | Content above the list (DropdownMenuHeader): account, subheading, search field or avatar group. |
| footer | Content below the list (DropdownMenuFooter): a full-width button or a version line. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| DDM1 | open | panel | opacity | 0 → 1 | duration-sm (200)ease-out | Shows at once |
| DDM2 | open | panel (transform origin at the trigger) | scale, translate toward the trigger | scale-press, -distance-rise → 1, 0 | duration-md (260)ease-settle | Shows at once |
| DDM3 | open | header, items, footer | opacity | 0 → 1 | duration-sm (200)ease-outdelay 60ms | Shows at once |
| DDM4 | close (select, Escape, outside click or trigger) | panel and content | opacity, scale | 1, 1 → 0, scale-press | duration-xs (160)ease-exit | Hides at once |
| DDM5 | open -> true / false | trigger chevron | rotate | 0deg → 180deg | duration-lg (320)ease-out | Swaps at once |
| DDM6 | an item is highlighted (pointer or arrow keys) | item row | background-color | transparent → bg-primary_hover | duration-2xs (120)ease-out | Swaps at once |
| DDM7 | a submenu opens (hover with delay, Right arrow or Enter) | submenu panel (origin at the parent row) | opacity, translate x | 0, -distance-rise (toward the parent row) → 1, 0 | duration-sm (200)ease-settle | Shows at once |
| DDM8 | a submenu closes | submenu panel | opacity | 1 → 0 | duration-2xs (120)ease-exit | Hides at once |
| DDM9 | a checkbox or radio item turns on | item indicator (check, box or dot) | opacity, scale | 0, scale-swap → 1, 1 | duration-sm (200)ease-out | Shows at once |
| DDM10 | hover the trigger | trigger | background-color, color | trigger default colors → trigger hover colors | duration-sm (200)ease-out | Swaps at once |
| DDM11 | keyboard focus on the trigger, or an item highlighted with the keyboard | trigger, item row | box-shadow (trigger ring) or inner 2px focus-ring border (row) | none → focus ring | duration-2xs (120)ease-out | Shows at once |
| DDM12 | context menu opens (right-click or the menu key) | panel (origin at the pointer) | opacity, scale | 0, scale-press → 1, 1 | duration-sm (200)ease-settle | Shows at once |
Accessibility
- Built on a menu primitive: the trigger has aria-haspopup menu and aria-expanded; the panel is role menu with menuitem, menuitemcheckbox and menuitemradio rows.
- Keyboard: Enter, Space or Down opens and highlights the first item, Up opens on the last; arrows move, Home and End jump, typing a letter jumps to a match, Right opens and Left closes a submenu, Escape closes and returns focus to the trigger.
- Disabled items stay in the list with aria-disabled and cannot be chosen.
- Shortcuts are shown as text and named with aria-keyshortcuts; the menu does not register them, the app does.
- Switch items use role menuitemcheckbox, so screen readers say on or off; the toggle drawing is aria-hidden.
- The Button link trigger is 20px tall: give it a 24 x 24px tap area or 4px of space around it (WCAG 2.5.8).
- Icon triggers need aria-label, for example "Account menu" or "More actions".
- Reduced motion: the panel and submenu show and hide at once and the chevron swaps.