Dropdown Menu React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
A React dropdown menu: icons, descriptions, shortcuts, badges, checkbox and radio items, submenus, and an action sheet on phones.
Click the trigger and the menu grows out of it on a quick spring. Hover or use the arrow keys to move the highlight (it wraps around), type a letter to jump to an item, Right opens a submenu and Left closes it, Enter or Space picks, Escape closes and focus returns to the trigger. The shortcuts written next to items run them while the menu is open, and an optional shortcut opens the menu from anywhere. Checkbox and radio items tick in place (the check draws itself) and keep the menu open; danger items turn red. On a phone the same menu slides up as an action sheet: 48px rows, the title centered on top, a Cancel button where the thumb is, and drag down to dismiss. A submenu row slides a new page in from the right with a Back row while the sheet resizes to fit. Opened from the keyboard, the sheet focuses its first row; the arrow keys move between rows, Right opens a submenu page and Left goes back. With reduced motion the menu and the pages fade instead of moving.
Dropdown Menu is a free React component in the React overlay components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add dropdown-menu, together with Button, Drawer, Motion presets, useControllableState, useMediaQuery and 2 more and the npm packages @radix-ui/react-dropdown-menu, lucide-react and motion.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add dropdown-menuUsage
Import DropdownMenu from @/components/ui/dropdown-menu.
import { Copy, Ellipsis, Pencil, Share2, Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { DropdownMenu, type MenuEntry } from "@/components/ui/dropdown-menu"
export function ListingActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) {
const items: MenuEntry[] = [
{ label: "Edit", icon: <Pencil />, shortcut: "mod+e", onSelect: onEdit },
{ label: "Duplicate", icon: <Copy />, shortcut: "mod+d" },
{
type: "submenu",
label: "Share",
icon: <Share2 />,
items: [{ label: "WhatsApp" }, { label: "Copy link", shortcut: "mod+c" }],
},
{ type: "separator" },
// a confirm dialog waits a frame, so focus is back on the trigger when it opens
{ label: "Delete", icon: <Trash2 />, tone: "danger", onSelect: () => requestAnimationFrame(onDelete) },
]
return (
<DropdownMenu
align="end"
items={items}
title="Pegas city bike"
description="$420 · Brooklyn"
trigger={
<Button variant="ghost" size="icon" radius="full" aria-label="Listing actions">
<Ellipsis />
</Button>
}
/>
)
}Props
defaultOpendefaultfalsebooleanStarts open (uncontrolled). Pass open and onOpenChange instead to control it.
openbooleanControlled open state. The root keeps it across the 768px breakpoint, so resizing never closes the menu.
onOpenChange(open: boolean) => voidCalled when the menu opens or closes: the trigger, an item, Escape, an outside click, the sheet's Cancel or a drag.
itemsMenuEntry[]The entries: items ({ label, icon, description, shortcut, badge, onSelect, href, tone: "danger", keepOpen }), { type: "checkbox", checked, onCheckedChange }, { type: "radio", value, onValueChange, options }, { type: "submenu", items }, { type: "group", label, items }, { type: "label" } and { type: "separator" }. Groups and radio sets get separators around them on their own. An onSelect that opens a dialog should wait a frame (requestAnimationFrame) so focus returns to the trigger first. With no enabled entry the trigger is disabled.
triggerReactNodeThe element that opens it, rendered with asChild: a single element that takes a ref (a Button, an Avatar wrapped in a button). It gets aria-keyshortcuts when a shortcut is set.
childrenReactNodeExtra rows after the items, built from the parts (DropdownMenuItem and friends). They render in the sheet too.
titledefault""ReactNodeThe action sheet's header on phones, centered and small like iOS (usually what the actions apply to). Not shown on desktop. The demo shows its sample while this is empty.
descriptiondefault""ReactNodeA quieter line under the sheet title, linked with aria-describedby. The demo shows its sample while this is empty.
sheetTitledefault"Actions"stringThe sheet's title for screen readers when there is no visible title.
showCanceldefaulttruebooleanThe sheet's Cancel button at the bottom, where the thumb is. The sheet still closes on a drag, a tap outside or Escape without it.
cancelLabeldefault"Cancel"stringThe label of the sheet's Cancel button.
backLabeldefault"Back"stringThe back row at the top of a submenu page in the sheet.
sizedefault"md""sm" | "md"Item height 32 / 36px with 13 / 14px text and 14 / 16px icons. 44px on touch either way; sheet rows are always 48px.
radiusdefault"md""none" | "sm" | "md" | "lg" | "full"Panel corners 0 / 12 / 16 / 24px (full falls back to lg). Items keep the panel radius minus its 6px padding, so lg rows are pills.
minWidthdefault192numberThe panel's minimum width in px. It grows for longer labels up to 384px (and never past the screen), then labels truncate.
sidedefault"bottom""top" | "right" | "bottom" | "left"The side of the trigger it opens on. It flips when there is no room.
aligndefault"start""start" | "center" | "end"How it lines up with the trigger. end suits a ⋯ button at the right edge of a row.
sideOffsetdefault8numberThe gap between the trigger and the panel, in px.
loopdefaulttruebooleanArrow keys wrap from the last item to the first (desktop and sheet). The demo says which it does.
modaldefaulttruebooleanWhile open, the page behind ignores the pointer and does not scroll (Radix). The sheet on phones is always modal.
responsivedefaulttruebooleanBelow 768px the menu becomes an action sheet with large rows and submenu pages. Turn it off to keep the floating menu.
shortcutdefault""stringA key combo in mod+k notation (⌘ on Apple, Ctrl elsewhere) that opens and closes the menu from anywhere on the page. Empty binds nothing.
onOpenAutoFocus(event: Event) => voidSheet only: runs when the action sheet takes focus. Opened with the keyboard it focuses the first row, opened by a tap the sheet itself; preventDefault to focus something else. The desktop menu focuses its first item on a key open (Radix).
containerHTMLElement | nullPortal target for the menu and the sheet (default document.body).
classNamesPartial<Record<"content" | "item" | "icon" | "label" | "description" | "shortcut" | "separator" | "sheet", string>>Extra classes for the parts: the panel (and sub panels), every row, row icons, labels, descriptions, shortcuts, separators, and the sheet.
classNamestringExtra classes for the panel (or the sheet), merged last. Other Radix content props (onCloseAutoFocus, alignOffset, avoidCollisions…) pass through.
Parts
DropdownMenuRoot
componentCompound API: holds the state and decides the mode once (the floating Radix menu, or the action sheet below 768px). Every part below reads it and renders for that mode, so one tree works on desktop and on phones. Props: { open, defaultOpen, onOpenChange, modal, responsive, shortcut, container, dir, children }.
DropdownMenuTrigger
componentOpens the menu; with asChild any element that takes a ref. It carries aria-keyshortcuts when the root has a shortcut. Props: Radix DropdownMenu.Trigger props (asChild).
DropdownMenuContent
componentThe panel (dropdown-menu-content with data-size, data-radius, data-side) with the popover spring, or the sheet on phones. Renders items first, then JSX children, and runs item shortcuts while open. Props: { items, size, radius, minWidth, title, description, sheetTitle, showCancel, cancelLabel, backLabel, container, classNames } & Radix DropdownMenu.Content props.
DropdownMenuItem
componentA row. href renders an <a>; asChild takes your router Link and keeps the row layout around its text (a Link that prevents the click still closes the menu). In the sheet it is a 48px button. Props: { icon, description, shortcut, badge, tone: "neutral" | "danger", keepOpen, href, target, rel, asChild, disabled, onSelect }.
DropdownMenuCheckboxItem
componentA row with a leading checkbox whose check draws itself; it stays open while toggling unless keepOpen is false. Props: { checked, defaultChecked, onCheckedChange, icon, description, shortcut, keepOpen = true, disabled } ("indeterminate" allowed).
DropdownMenuRadioGroup
componentHolds one choice from a set of radio items. Props: { value, defaultValue, onValueChange }.
DropdownMenuRadioItem
componentOne choice in a radio group, marked with a trailing check. Pass keepOpen={false} for a sort menu that closes on pick. Props: { value, icon, description, keepOpen = true, disabled }.
DropdownMenuSub
componentA nested menu: holds the open state of the side panel on desktop, a pushed page with a Back row in the sheet. Props: { open, defaultOpen, onOpenChange }.
DropdownMenuSubTrigger
componentThe row with a chevron that opens the nested menu. Props: { icon, description, badge, disabled }.
DropdownMenuSubContent
componentThe side panel (or sheet page) of a nested menu. Props: { items, sideOffset, alignOffset, container }.
DropdownMenuGroup
componentGroups related items into a section. Takes Radix Group props.
DropdownMenuLabel
componentThe small muted label of a section. Takes Radix Label props.
DropdownMenuSeparator
componentA hairline between sections. Takes Radix Separator props.
MenuSheet
componentThe action sheet on its own, with its own drawer root: open it from a long press or a split button (context-menu and button-group use it on phones). Props: { open, defaultOpen, onOpenChange, items, title, description, sheetTitle, showCancel, cancelLabel, backLabel, loop, container, classNames, className, children, onCloseAutoFocus }.
MenuItemContent
componentThe inside of a row (checkbox, icon, label and description, badge, shortcut, chevron or radio check) for any Radix menu item; slot sets the data-slot prefix, e.g. context-menu. Props: { label, icon, description, badge, shortcut, submenu, indicator: "checkbox" | "radio", checked, size, sheet, tone, slot, classNames }.
MENU_CLASSES
constantThe shared class recipe (content, item, itemWithDescription, icon, label, description, shortcut, badge, groupLabel, separator, subChevron, check, sheetItem, sheetSeparator...), so context menus and split buttons match.
MENU_ITEM_SIZES
constantThe row classes per size: sm is 32px and md 36px, both 44px on a coarse pointer, with matching gap, padding, type and icon size.
MENU_RADII
constantThe panel and row corners per radius; rows take the panel radius minus its 6px padding so the shapes stay concentric.
MenuEntry
typeOne entry of items: an item, checkbox, radio group, submenu, group, label or separator, told apart by type.
DropdownMenuProps
typeThe props of DropdownMenu, for wrappers that build menus from your own data.
Examples
User menu
An Avatar trigger, a count badge, a theme radio group and shortcuts, with the name as the sheet title on phones.
Sort menu
A radio group with keepOpen off: picking an option closes the menu and renames the button.
Column visibility
Checkbox items stay open while you toggle; the table follows along.
Nested submenus
Two levels deep. Right and Left move between levels; on a phone each level is a page.
Sizes and radii
size sm or md, and every radius; the items follow the panel's corners.
Parts with links
The parts API with asChild anchors, as with a router Link: navigation runs and the menu closes.
Action sheet on a phone
MenuSheet in a phone frame: 48px rows, a Cancel button at the thumb and submenu pages.
Dependencies
Files
- components/ui/dropdown-menu.tsx
FAQ
Is the React Dropdown Menu component free?
Yes. The source is free and needs no account: copy it from the Code tab or install it with the CLI.
How do I install Dropdown Menu?
Run npx wingo-ui@latest add dropdown-menu. It also adds the Wingo UI items it builds on (Button, Drawer, Motion presets, useControllableState, useMediaQuery and 2 more) and the npm packages @radix-ui/react-dropdown-menu, lucide-react and motion. With shadcn, run npx shadcn@latest add https://wingo-ui.com/r/dropdown-menu.json.
How does Dropdown Menu behave on phones?
Below 768px the menu becomes an action sheet with large rows and submenu pages. Turn it off to keep the floating menu. The responsive prop controls this and is on by default.
See also
Used in
More React overlay components
Articles using this component
- React Action Sheet: iOS-Style, Async and Responsive
Mobile UI in React9 min read
