Lunia DS

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.

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

Usage

The demo above, as code.

demo.tsx
'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.

PropTypeDefaultDescription
openbooleanundefinedControlled open state.
defaultOpenbooleanfalseOpen 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.
sideOffsetnumber4Gap between the trigger and the panel, in px.
widthnumber240Panel width in px (Figma 216 to 248).
maxHeightnumberundefinedPanel height before the list scrolls; the scroll bar shows when the list is longer.
modalbooleantrueBlocks page scroll and outside pointer events while open.
disabledbooleanfalseTurns off the trigger.
EventDescription
openChangeThe menu opened or closed. Payload: the new open state.
selectOn DropdownMenuItem: the item was chosen with a click, Enter or Space. The menu closes unless the handler prevents it.
checkedChangeOn DropdownMenuCheckboxItem and DropdownMenuSwitchItem: the item was turned on or off. Payload: the new state.
valueChangeOn DropdownMenuRadioGroup: a radio item was chosen. Payload: its value.
SlotDescription
triggerThe trigger element. Any Lunia button, an avatar or an account card.
defaultMenu content: DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup with DropdownMenuRadioItem, DropdownMenuSwitchItem, DropdownMenuSubmenu, DropdownMenuDivider.
headerContent above the list (DropdownMenuHeader): account, subheading, search field or avatar group.
footerContent 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
DDM1openpanelopacity0 → 1duration-sm (200)ease-outShows at once
DDM2openpanel (transform origin at the trigger)scale, translate toward the triggerscale-press, -distance-rise → 1, 0duration-md (260)ease-settleShows at once
DDM3openheader, items, footeropacity0 → 1duration-sm (200)ease-outdelay 60msShows at once
DDM4close (select, Escape, outside click or trigger)panel and contentopacity, scale1, 1 → 0, scale-pressduration-xs (160)ease-exitHides at once
DDM5open -> true / falsetrigger chevronrotate0deg → 180degduration-lg (320)ease-outSwaps at once
DDM6an item is highlighted (pointer or arrow keys)item rowbackground-colortransparent → bg-primary_hoverduration-2xs (120)ease-outSwaps at once
DDM7a submenu opens (hover with delay, Right arrow or Enter)submenu panel (origin at the parent row)opacity, translate x0, -distance-rise (toward the parent row) → 1, 0duration-sm (200)ease-settleShows at once
DDM8a submenu closessubmenu panelopacity1 → 0duration-2xs (120)ease-exitHides at once
DDM9a checkbox or radio item turns onitem indicator (check, box or dot)opacity, scale0, scale-swap → 1, 1duration-sm (200)ease-outShows at once
DDM10hover the triggertriggerbackground-color, colortrigger default colors → trigger hover colorsduration-sm (200)ease-outSwaps at once
DDM11keyboard focus on the trigger, or an item highlighted with the keyboardtrigger, item rowbox-shadow (trigger ring) or inner 2px focus-ring border (row)none → focus ringduration-2xs (120)ease-outShows at once
DDM12context menu opens (right-click or the menu key)panel (origin at the pointer)opacity, scale0, scale-press → 1, 1duration-sm (200)ease-settleShows 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.