Dropdown Menu

Free · MIT

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

MarkdownOpen in v0

Installation

$ npx shadcn@latest add https://goodui.dev/r/dropdown-menu.json

The 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

Use it for
  • 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.
Reach for something else
  • 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 needUse
Row actions in tables and listsan iconOnly Button as trigger, align="end", size="sm"
Calm app menusvariant="default", highlight="soft", animation="scale" (default)
Dense pro tools (editors, calendars)highlight="bar" or "outline", size="sm", animation="fade"
macOS-like menus over contentvariant="inverted" or "glass"
Playful or retro brandsvariant="neo" with highlight="solid"
Right-click on a surfacemode="context" and wrap the surface as children
Shortcuts that actually workshortcut 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

PropTypeDefault
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.
number6
openboolean—
defaultOpenbooleanfalse
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.
booleantrue
disabledboolean—
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 / SpaceOn the trigger: opens the menu and focuses the first item.
Arrow Down / Arrow UpOn 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 / SpaceActivates the item: runs onSelect, toggles a checkbox, picks a radio, opens a sub-menu. Links follow their href.
LettersType-ahead: focuses the next item whose label starts with the typed text.
EscapeCloses the current level; on the root menu returns focus to the trigger (or the element that was focused before a context menu).
TabCloses every level, returns focus to the trigger and lets Tab continue from there.
Shift + F10 / Menu keyIn context mode, on a focused element inside the area: opens the menu at that element with the first item focused.
Item shortcutsWith 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