affectUI › Components › Navigation

Dropdown Menu

Actions and choices behind any trigger – icons, shortcuts, checks, red items and submenus, a gliding highlight, type-ahead and the full menu keyboard.

Free and open source (MIT). Install: npx shadcn@latest add https://www.affectui.com/r/dropdown-menu.json · Import from @/components/ui/dropdown-menu

When to use

  • Actions on one thing behind a ⋯ button: rerun, duplicate, copy link, move, delete
  • A short choice that changes a view right away – sort order, period, density – shown as checked items
  • Account and workspace menus with a header (who is signed in) and submenus (language, appearance)

When not to use

  • Picking a value for a form field – use the Select
  • Searching across many commands or pages – use the Command Menu
  • A short form or an explanation next to a button – use the Popover
  • Filters with several groups and counts – use the Filter Menu

Usage

import { Copy01Icon, Delete02Icon, Folder01Icon, MoreHorizontalIcon, RepeatIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { DropdownMenu } from "@/components/ui/dropdown-menu";

export function RunActions({ run }: { run: { id: string; name: string } }) {
  return (
    <DropdownMenu
      align="end"
      items={[
        { label: "Rerun", icon: RepeatIcon, shortcut: "⌘R", onSelect: () => rerun(run.id) },
        { label: "Duplicate", icon: Copy01Icon, onSelect: () => duplicate(run.id) },
        {
          label: "Move to",
          icon: Folder01Icon,
          divider: true,
          submenu: folders.map((f) => ({ label: f.name, icon: f.icon, checked: f.id === run.folder, onSelect: () => move(run.id, f.id) })),
        },
        { label: "Delete run", icon: Delete02Icon, danger: true, divider: true, onSelect: () => remove(run.id) },
      ]}
      trigger={({ triggerProps }) => (
        <button type="button" aria-label={`Actions for ${run.name}`} {...triggerProps}>
          <HugeiconsIcon icon={MoreHorizontalIcon} size={16} />
        </button>
      )}
    />
  );
}

Props

NameTypeDefaultDescription
trigger({ open, toggle, triggerProps }) => ReactNodeDraws the trigger. Spread triggerProps on it (id, aria-haspopup, aria-expanded, aria-controls, onClick) – or call toggle yourself
itemsDropdownMenuItem[]{ label, description?, icon?, logo?, checked?, type?, danger?, shortcut?, divider?, disabled?, keepOpen?, submenu?, onSelect? }
item.iconReactNode | HugeIconA 16px glyph before the label – an inline SVG, or a Hugeicons icon object passed as it is
item.logoReactNodeA brand mark in its own colors in place of the icon (Gmail, Slack, a flag)
item.checkedbooleanMakes the item a choice with a check on the right; leave it out for a plain action
item.type"radio" | "checkbox""radio"radio: one of a set (menuitemradio) · checkbox: on or off on its own (menuitemcheckbox)
item.dangerbooleanRed label and icon, a red-tinted highlight – for Delete and the like
item.shortcutstringShown on the right, e.g. "⌘R" – only shown, wire the keys up yourself
item.dividerbooleanA hairline above the item, to start a group
item.disabledbooleanShown faint; can't be picked and the arrow keys skip it
item.keepOpenbooleanThe menu stays open after the pick – for checkboxes you tick several of
item.submenuDropdownMenuItem[]Opens beside the item on hover, →, Enter or Space; ← or Esc steps back
align"start" | "end""start"Which edges of menu and trigger line up; switches when the menu would stick out
side"bottom" | "top""bottom"Where it opens; flips when the other side has more room
titlestringA small gray heading above the items – also the menu's accessible name
headerReactNodeAnything above the items, e.g. the signed-in user
open / defaultOpen / onOpenChangeboolean / boolean / (open) => voidControlled or uncontrolled open state
widthnumber | stringThe menu's width; by default it fits the items, at least 180px
menuClassNamestringClasses on the menu panel itself – className goes on the wrapper around the trigger and the menu. The panel carries the trigger's width as a CSS variable, e.g. min-width: var(--ui-dm-trigger-w) for a menu as wide as its field
emptyReactNode"Nothing here yet"Shown when items is empty
classNamestringOn the inline wrapper around the trigger
aria-labelstringNames the menu when there is no title (otherwise the trigger names it)

Keyboard

  • Enter / Space / ↓: On the trigger: open with the checked or first item focused
  • ↑: On the trigger: open with the last item focused
  • ↓ / ↑: Next or previous item, wrapping around; disabled ones are skipped
  • Home / End: First or last item
  • A–Z: Jump to the next item that starts with what you type
  • Enter / Space: Pick the item, or open its submenu
  • → / ←: Into a submenu, and back out of it
  • Esc: Close the submenu, or the menu – the focus goes back to the trigger
  • Tab: Close the menu and move on from the trigger

Accessibility

  • The menu button pattern: the trigger has aria-haspopup="menu", aria-expanded and aria-controls; the list is role="menu" named by the title or the trigger
  • Items are menuitem, menuitemradio or menuitemcheckbox with aria-checked; an item with a submenu has aria-haspopup and aria-expanded; dividers are separators
  • Real focus moves through the items (roving focus), so every screen reader follows; disabled items carry aria-disabled
  • Opened with the pointer, the focus waits on the menu itself – no item looks picked until you hover or press a key
  • However it closes, the focus goes back to the trigger – unless the pick moved it on purpose (e.g. into a rename field)
  • The ring shows only under keyboard focus (:focus-visible), inside the row

Responsive

  • It lives in the top layer (popover), so a card's overflow or z-index never cuts it; it follows its trigger on scroll
  • Opens where there is room – under or above the trigger, the edges line up the other way near the screen's side; a long menu scrolls
  • A submenu opens to the right, to the left when there is no room, and under its item on a narrow phone
  • Long labels end in … with the full text on hover; touch screens get 40px rows
  • Dark mode built in: switches when an ancestor (usually <html>) has the class dark or data-theme="dark"

Requirements

  • React 19
  • Optional: @hugeicons/core-free-icons – its icon objects can be passed straight to icon

Related

  • Popover – A small floating card attached to its trigger – a title and quiet line, any content, flips to stay on screen, closes on a press outside or Escape and hands the focus back
  • Select – Pick one option in the Text Field's look – icons and a second line per option, a gliding highlight, type-ahead and a list that never gets cut off
  • Command Menu – Search and run anything from one field – tool actions with logos and ⌘1–⌘5, everyday commands with icons
  • Filter Menu – Grouped filters with removable chips, collapsible checkbox groups, Clear all and full keyboard control
  • Profile Menu – Account card with the signed-in user, menu actions and profile setup progress