Dropdown Menu
Free · MITMenu button and right-click context menu with default, glass, neo, inverted and flat surfaces, soft, solid, bar or outline highlights, tones, sizes, shapes and shadows; icons, descriptions, badges and live keyboard shortcuts, checkbox items, radio groups, labelled groups, destructive items and nested sub-menus with hover intent and a safe triangle; scale, fade, slide or stagger entrances, type-ahead and full APG keyboard, portaled and flipped to stay on screen.
Installation
$ npx shadcn@latest add https://goodui.dev/r/dropdown-menu.jsonThe file lands in components/goodui/dropdown-menu.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/dropdown-menu works too.
Usage
import { MoreHorizontal, Pencil, Copy, CalendarClock, Trash2 } from "lucide-react"
import { Button } from "@/components/goodui/button"
import { DropdownMenu } from "@/components/goodui/dropdown-menu"
export function BookingActions({ booking }: { booking: { id: string; client: string } }) {
const [remind, setRemind] = React.useState(true)
const [status, setStatus] = React.useState("confirmed")
return (
<DropdownMenu
align="end"
trigger={
<Button variant="ghost" tone="neutral" size="sm" iconOnly aria-label={`Actions for ${booking.client}`}>
<MoreHorizontal />
</Button>
}
items={[
{ label: "Edit booking", icon: <Pencil />, shortcut: "Mod+E", onSelect: () => edit(booking.id) },
{ label: "Duplicate", icon: <Copy />, shortcut: "Mod+D", onSelect: () => duplicate(booking.id) },
{
type: "sub",
label: "Reschedule",
icon: <CalendarClock />,
items: [
{ label: "Tomorrow, same time", onSelect: () => moveBy(booking.id, 1) },
{ label: "Next week", onSelect: () => moveBy(booking.id, 7) },
],
},
{ type: "separator" },
{ type: "checkbox", id: "remind", label: "Send reminder", checked: remind, onCheckedChange: setRemind },
{
type: "radio",
id: "status",
label: "Status",
value: status,
onValueChange: setStatus,
options: [
{ value: "confirmed", label: "Confirmed" },
{ value: "pending", label: "Pending deposit" },
],
},
{ type: "separator" },
{ label: "Cancel booking", icon: <Trash2 />, destructive: true, onSelect: () => cancel(booking.id) },
]}
/>
)
}When to use
- A list of actions behind one button: row actions in a table (Edit, Duplicate, Cancel booking), an Export button, a user menu.
- Right-click menus on a canvas, calendar or file list (mode="context"), with long-press on touch.
- Toggles and one-of-many settings that belong with the actions: visible columns (checkbox items), sort order or status (radio group).
- Deep action trees that need sub-menus, with keyboard shortcuts shown next to each action and optionally bound as hotkeys.
- Picking a form value from a list: use select; a menu runs actions and does not hold a form value.
- Typing to search thousands of options: use combobox.
- Two or three actions that fit on screen: show them as buttons, so nothing is hidden.
- Site navigation with many links: use a navigation menu or sidebar; menus are for actions on the current thing.
| If you need | Use |
|---|---|
| Row actions in tables and lists | an iconOnly Button as trigger, align="end", size="sm" |
| Calm app menus | variant="default", highlight="soft", animation="scale" (default) |
| Dense pro tools (editors, calendars) | highlight="bar" or "outline", size="sm", animation="fade" |
| macOS-like menus over content | variant="inverted" or "glass" |
| Playful or retro brands | variant="neo" with highlight="solid" |
| Right-click on a surface | mode="context" and wrap the surface as children |
| Shortcuts that actually work | shortcut on items plus bindShortcuts |
Examples
Context menu on a calendar
<DropdownMenu
mode="context"
items={[
{ label: "New booking here", icon: <CalendarPlus />, onSelect: create },
{ label: "Block this time", onSelect: block },
]}
>
<WeekGrid />
</DropdownMenu>Live hotkeys
<DropdownMenu
bindShortcuts
label="Export"
items={[
{ label: "Download PDF", shortcut: "Mod+Shift+F", onSelect: downloadPdf },
{ label: "Email to client", shortcut: "Mod+Shift+E", onSelect: email },
]}
/>Translated
<DropdownMenu
items={items}
labels={{ trigger: "Aktionen", contextMenu: "Kontextmenü", keyName: (k) => ({ Ctrl: "Strg", Shift: "Umschalt", Del: "Entf" })[k] ?? k }}
/>API reference
| Prop | Type | Default |
|---|---|---|
| items* | DropdownMenuItem[] | — |
| aria-label Defines a string value that labels the current element. Names the menu. Defaults to the trigger's text (menu) or labels.contextMenu (context). | string | — |
| mode menu opens from the trigger; context opens at the pointer on right-click (or long-press) inside children. | "menu" | "context" | "menu" |
| trigger Your trigger element (usually a button). It gets the ARIA and handlers. Without it a built-in button is shown. | React.ReactElement | — |
| label Text of the built-in trigger. | React.ReactNode | — |
| children Context mode: the area that opens the menu on right-click. | React.ReactNode | — |
| variant Menu surface: default (popover), glass, neo, inverted or flat. | "default" | "glass" | "neo" | "inverted" | "flat" | "default" |
| tone Accent for the highlight, check marks and bars. | "primary" | "neutral" | "success" | "warning" | "danger" | "info" | "primary" |
| highlight How the active item is painted: soft tint, solid tone fill, a bar at the start edge, or an outline. | "soft" | "solid" | "bar" | "outline" | "soft" |
| size | "sm" | "md" | "lg" | "md" |
| shadow | "none" | "sm" | "md" | "lg" | "md" |
| shape | "square" | "rounded" | "pill" | "rounded" |
| animation How menus open. stagger cascades the items in. Reduced motion turns it into a short fade. | "none" | "fade" | "scale" | "slide" | "stagger" | "scale" |
| side Preferred side; flips when there is no room. | "top" | "bottom" | "left" | "right" | "bottom" |
| align Alignment along the side; start follows the reading direction. | "start" | "center" | "end" | "start" |
| sideOffset Gap between trigger and menu in px. | number | 6 |
| open | boolean | — |
| defaultOpen | boolean | false |
| onOpenChange | (open: boolean) => void | — |
| bindShortcuts Also run items' shortcuts as global hotkeys while the page is focused. | boolean | — |
| loop Arrow keys wrap from the last item to the first. | boolean | true |
| disabled | boolean | — |
| menuClassName Class for every menu panel (root and sub-menus). | string | — |
| labels Text for built-in controls and screen readers; pass translations here. | Partial<DropdownMenuLabels> | — |
Also accepts every <div> attribute.
Accessibility
| Enter / Space | On the trigger: opens the menu and focuses the first item. |
| Arrow Down / Arrow Up | On the trigger: opens the menu on the first or last item. In the menu: moves between items, skipping disabled ones and wrapping (loop). |
| Home / End (also Page Up / Page Down) | First or last item. |
| Arrow Right (Arrow Left in RTL) | On a sub-menu item: opens the sub-menu and focuses its first item. |
| Arrow Left (Arrow Right in RTL) | In a sub-menu: closes it and returns focus to its parent item. |
| Enter / Space | Activates the item: runs onSelect, toggles a checkbox, picks a radio, opens a sub-menu. Links follow their href. |
| Letters | Type-ahead: focuses the next item whose label starts with the typed text. |
| Escape | Closes the current level; on the root menu returns focus to the trigger (or the element that was focused before a context menu). |
| Tab | Closes every level, returns focus to the trigger and lets Tab continue from there. |
| Shift + F10 / Menu key | In context mode, on a focused element inside the area: opens the menu at that element with the first item focused. |
| Item shortcuts | With bindShortcuts, each item's shortcut runs it from anywhere on the page (plain-letter combos are ignored while typing in a field). |
- Follows the WAI-ARIA menu button pattern: the trigger gets aria-haspopup="menu", aria-expanded and aria-controls; the panel is role="menu" labelled by the trigger (or aria-label).
- Items are role="menuitem", "menuitemcheckbox" or "menuitemradio" with aria-checked; sub-menu items carry aria-haspopup and aria-expanded; groups are role="group" labelled by their visible label; separators are role="separator".
- Focus really moves to items (roving focus), so screen readers announce each item. Disabled items stay visible with aria-disabled and are skipped by the arrow keys.
- Shortcuts are visible keycaps and are exposed as aria-keyshortcuts (Mod becomes Meta on Mac and Control elsewhere).
- Your trigger element keeps its own name; icon-only triggers need an aria-label. Context menus are named by labels.contextMenu.
- Menus are portaled to document.body, so overflow: hidden parents never clip them, and they flip and shift to stay in the viewport.
Motion
- scale (default): the menu fades in from 0.94 scale at the edge facing the trigger on a spring (stiffness 560, damping 36, mass 0.6). fade: 160 ms opacity. slide: moves 8px in from the trigger's side. stagger: items slide in 6px, 24 ms apart, 200 ms each.
- Menus close with a 100 ms fade. Check marks and radio dots pop in with a short overshoot; the bar highlight grows in 160 ms; the built-in trigger's chevron turns 180°.
- Reduced motion: menus only fade (100 ms), items do not cascade and CSS transitions are off. Nothing depends on the animation to work.
- Menus mount only while open. Placement is measured once per open and updated on scroll, resize and size changes, throttled to one animation frame.
- Item lists are not virtualized; for hundreds of options use combobox.
Notes for AI
Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.
- items is data: actions { label, icon?, description?, shortcut?, badge?, destructive?, href?, onSelect?, disabled?, keepOpen? }, { type: "checkbox", id, … }, { type: "radio", id, options }, { type: "sub", label, items }, { type: "group", label?, items }, { type: "label" }, { type: "separator" }.
- Pass your own trigger element (e.g. <Button iconOnly aria-label=…>); it is cloned with ref, ARIA and click/key handlers. Without trigger a built-in button shows label.
- Checkbox items stay open after a toggle and radio items close; flip that with keepOpen.
- Checkbox and radio state is uncontrolled via defaultChecked/defaultValue, or controlled via checked/value with onCheckedChange/onValueChange.
- Shortcuts use Mod for ⌘ on Mac and Ctrl elsewhere: "Mod+Shift+E". They are display-only unless bindShortcuts is set.
- Use mode="context" with children for right-click menus; do not attach onContextMenu yourself.
- Do not use it as a form select; use select or combobox for values.
FAQ
Does the menu get clipped inside a scrolling table?
No. It renders in a portal on document.body with fixed positioning, flips to the other side when there is no room and shifts to stay inside the viewport.
How do sub-menus behave with the mouse?
They open after about 110 ms of hover. While you move the pointer diagonally toward an open sub-menu, a safe triangle keeps it open even if you cross other items.
Can items be links?
Yes, give an item href (and target). It renders an <a role="menuitem">, so middle-click and open-in-new-tab work.
Related
Fully custom single or multi select: outline, filled, ghost, underline, pill and neo triggers; icons, avatars, descriptions and groups; search with match highlight, chips, clear, type-ahead and full keyboard; animated menu that flips up when space runs out.
Autocomplete combobox (ARIA 1.2) with outline, filled, ghost, underline, pill and neo fields, tones, sizes and shadows; local ranked matching or debounced async search with abort, cache, loading, empty and error-with-retry states, every matched word highlighted, groups, avatars, single or multi-select chips, an “Add …” create row and automatic virtualization for 10,000+ rows, in a portaled list that flips above the field.
One button with ten variants, six tones, seven shadows, ten animations, three shapes and five sizes that all combine, plus loading and icon slots.
Keyboard keys and combos in flat, raised 3D, outline, glass, neo and solid styles. Keycaps depress when the real keys are pressed, Mod maps to ⌘ or Ctrl, and onTrigger turns any combo or G-then-T sequence into a working hotkey.