Dropdown Menu
A menu of actions triggered by a button. Supports items, labels, separators, and sub-menus.
Installation
Usage
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from "@/components/ui/dropdown-menu"Examples
Default
Switch items
DropdownMenuSwitchItem is for a setting the menu TOGGLES and keeps showing; DropdownMenuCheckboxItem is for a choice the menu RECORDS and dismisses. That is why this one does not close on click: a switch that flips and then vanishes gives you no chance to see the state you just set. The Switch is decorative markup, so the row keeps one accessible name and one hit target.
Radio filter
DropdownMenuRadioGroup turns the menu into a single-select filter, triggered by a Kebab. The checked item is marked by a checkmark, matching the Select selection language; hover and keyboard focus take the solid bg-accent highlight.
Positioning
Control where the menu opens with side (top/right/bottom/left) and align (start/center/end) on DropdownMenuContent; both pass straight through to Base UI's positioner. Pick a cell and the code below updates to match your selection.
side="bottom" align="start"
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| sideDropdownMenuContent | "top" | "right" | "bottom" | "left" | "bottom" | Preferred edge of the trigger to render against. |
| alignDropdownMenuContent | "start" | "center" | "end" | "center" | Alignment against the trigger along the chosen side. |
| sideOffsetDropdownMenuContent | number | 4 | Distance in pixels between the menu and the trigger. |
| avoidCollisionsDropdownMenuContent | boolean | true | When true, flips/shifts the menu to stay in view. Set false to always honor side. |
| variantDropdownMenuItem | "default" | "destructive" | "default" | Visual intent of a menu item; destructive tints the item and its icon red. |
| insetDropdownMenuItem, DropdownMenuLabel, DropdownMenuSubTrigger | boolean | - | Adds left padding so the item aligns with checkbox/radio items. |
| checkedDropdownMenuCheckboxItem, DropdownMenuSwitchItem | boolean | "indeterminate" | - | Controlled checked state, paired with onCheckedChange. |
| renderDropdownMenuTrigger | React.ReactElement | - | Replaces the default trigger button with your own element, e.g. render={<Button>Open</Button>}. Base UI merges props onto it. |
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { CheckIcon, ChevronRightIcon } from "lucide-react"
import { cn } from "@/registry/lib/utils"
import { Switch } from "@/registry/ui/switch"
/**
* Tracks whether a label has a group to name.
*
* Base UI's `Menu.GroupLabel` reads `MenuGroupContext` and THROWS when there is
* none, so a bare `<DropdownMenuLabel>` (the common "My Account" heading above a
* separator) crashes the app the moment the menu opens. It cannot be caught by a
* build: the popup only mounts on open, so SSR and typecheck both pass.
*
* Inside a group the label stays the real `GroupLabel`, which is what wires
* `aria-labelledby` from the group to this element. Standalone it degrades to a
* plain div, which is honestly all an ungrouped label is: a heading that names
* nothing in particular.
*/
const DropdownMenuGroupContext = React.createContext(false)
function DropdownMenu({
...props
}: React.ComponentProps<typeof MenuPrimitive.Root>) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof MenuPrimitive.Portal>) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof MenuPrimitive.Trigger>) {
return (
<MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
)
}
function DropdownMenuContent({
className,
align = "start",
side,
sideOffset = 4,
alignOffset,
collisionAvoidance,
...props
}: React.ComponentProps<typeof MenuPrimitive.Popup> &
Pick<
React.ComponentProps<typeof MenuPrimitive.Positioner>,
"align" | "side" | "sideOffset" | "alignOffset" | "collisionAvoidance"
>) {
return (
<MenuPrimitive.Portal>
{/* z-50 belongs HERE, on the Positioner, not on the Popup below.
Base UI positions the Positioner (`position: fixed`) and leaves the
Popup `position: static`, and z-index is ignored on a static element
that is not a flex or grid item. A z-50 written on the Popup is inert:
the menu ends up at `z-index: auto` in the root stacking context, so a
sticky header at z-40, or any positioned page element, paints over it.
Nothing about it looks wrong in the source, which is why it survived. */}
<MenuPrimitive.Positioner
className="z-50"
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
collisionAvoidance={collisionAvoidance}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn(
"scrollbar-custom bg-popover text-popover-foreground transition-[opacity,scale,translate] motion-reduce:transition-[opacity] ease-out duration-200 data-[ending-style]:duration-150 data-[starting-style]:opacity-0 data-[ending-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:scale-95 data-[side=bottom]:data-[starting-style]:-translate-y-2 data-[side=bottom]:data-[ending-style]:-translate-y-2 data-[side=top]:data-[starting-style]:translate-y-2 data-[side=top]:data-[ending-style]:translate-y-2 data-[side=right]:data-[starting-style]:-translate-x-2 data-[side=right]:data-[ending-style]:-translate-x-2 data-[side=left]:data-[starting-style]:translate-x-2 data-[side=left]:data-[ending-style]:translate-x-2 max-h-(--available-height) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg p-1 shadow-ring-md",
className
)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof MenuPrimitive.Group>) {
return (
<DropdownMenuGroupContext.Provider value={true}>
<MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
</DropdownMenuGroupContext.Provider>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof MenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 pointer-coarse:py-3 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3 [&_svg:not([class*='text-'])]:text-muted-foreground data-[highlighted]:bg-accent-100 dark:data-[highlighted]:bg-accent-900 data-[highlighted]:text-accent data-[highlighted]:[&_svg:not([class*='text-'])]:text-accent data-[variant=destructive]:text-destructive data-[variant=destructive]:[&_svg:not([class*='text-'])]:text-destructive data-[variant=destructive]:data-[highlighted]:bg-destructive-100 dark:data-[variant=destructive]:data-[highlighted]:bg-destructive-900 data-[variant=destructive]:data-[highlighted]:text-destructive data-[variant=destructive]:data-[highlighted]:[&_svg:not([class*='text-'])]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof MenuPrimitive.CheckboxItem>) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"data-[highlighted]:bg-accent-100 dark:data-[highlighted]:bg-accent-900 data-[highlighted]:text-accent relative flex cursor-default items-center gap-2 rounded-md py-1.5 pointer-coarse:py-3 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon className="size-3" />
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
/**
* A checkbox row that wears a Switch instead of a checkmark.
*
* Use it for a setting the menu TOGGLES and keeps showing (dark mode is the
* canonical case), and `DropdownMenuCheckboxItem` for a choice the menu
* RECORDS and dismisses. That difference is why this one sets
* `closeOnClick={false}`: a switch that flips and then vanishes gives you no
* chance to see the state you just set, and flipping it back means reopening
* the menu.
*
* The Switch is decorative markup, not a second control. It is `aria-hidden`,
* untabbable, and `pointer-events-none`, so the row keeps ONE accessible name
* and ONE hit target: the menu item itself, which already reports checked
* state to a screen reader. A focusable switch inside a focusable menu item is
* a keyboard trap and a duplicate announcement.
*/
function DropdownMenuSwitchItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof MenuPrimitive.CheckboxItem>) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-switch-item"
closeOnClick={false}
className={cn(
// Matches DropdownMenuCheckboxItem exactly except for the indicator:
// the switch sits at the end, so there is no left gutter to reserve
// and the row takes symmetric `px-2` instead of `pr-2 pl-8`.
"data-[highlighted]:bg-accent-100 dark:data-[highlighted]:bg-accent-900 data-[highlighted]:text-accent relative flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 pointer-coarse:py-3 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3",
className
)}
checked={checked}
{...props}
>
{children}
<Switch
size="sm"
checked={!!checked}
aria-hidden
tabIndex={-1}
className="pointer-events-none ml-auto"
/>
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof MenuPrimitive.RadioGroup>) {
return (
// A RadioGroup supplies MenuGroupContext too, so a label nested in one is
// safe and correctly names the choice set.
<DropdownMenuGroupContext.Provider value={true}>
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
</DropdownMenuGroupContext.Provider>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof MenuPrimitive.RadioItem>) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"data-[highlighted]:bg-accent-100 dark:data-[highlighted]:bg-accent-900 data-[highlighted]:text-accent relative flex cursor-default items-center gap-2 rounded-md py-1.5 pointer-coarse:py-3 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenuPrimitive.RadioItemIndicator>
<CheckIcon className="size-3" />
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenuPrimitive.GroupLabel> & {
inset?: boolean
}) {
// See DropdownMenuGroupContext: GroupLabel throws outside a group.
const inGroup = React.useContext(DropdownMenuGroupContext)
// The generic is pinned on purpose: a bare React.ElementType collapses the
// merged props to `never` under a stricter tsconfig than this repo's, which
// then rejects every prop spread below. Pinning it costs nothing here and
// keeps the component compiling in a consumer with strict settings.
const Component = (inGroup ? MenuPrimitive.GroupLabel : "div") as React.ElementType<
React.ComponentProps<typeof MenuPrimitive.GroupLabel>
>
return (
<Component
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof MenuPrimitive.Separator>) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof MenuPrimitive.SubmenuRoot>) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenuPrimitive.SubmenuTrigger> & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"data-[highlighted]:bg-accent-100 dark:data-[highlighted]:bg-accent-900 data-[highlighted]:text-accent data-[popup-open]:bg-accent-100 dark:data-[popup-open]:bg-accent-900 data-[popup-open]:text-accent [&_svg:not([class*='text-'])]:text-muted-foreground data-[highlighted]:[&_svg:not([class*='text-'])]:text-accent data-[popup-open]:[&_svg:not([class*='text-'])]:text-accent flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 pointer-coarse:py-3 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-3" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
className,
side,
align,
sideOffset,
alignOffset,
collisionAvoidance,
...props
}: React.ComponentProps<typeof MenuPrimitive.Popup> &
Pick<
React.ComponentProps<typeof MenuPrimitive.Positioner>,
"align" | "side" | "sideOffset" | "alignOffset" | "collisionAvoidance"
>) {
return (
<MenuPrimitive.Portal>
{/* z on the Positioner, never the Popup. See DropdownMenuContent. */}
<MenuPrimitive.Positioner
className="z-50"
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
collisionAvoidance={collisionAvoidance}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground transition-[opacity,scale,translate] motion-reduce:transition-[opacity] ease-out duration-200 data-[ending-style]:duration-150 data-[starting-style]:opacity-0 data-[ending-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:scale-95 data-[side=bottom]:data-[starting-style]:-translate-y-2 data-[side=bottom]:data-[ending-style]:-translate-y-2 data-[side=top]:data-[starting-style]:translate-y-2 data-[side=top]:data-[ending-style]:translate-y-2 data-[side=right]:data-[starting-style]:-translate-x-2 data-[side=right]:data-[ending-style]:-translate-x-2 data-[side=left]:data-[starting-style]:translate-x-2 data-[side=left]:data-[ending-style]:translate-x-2 min-w-32 origin-(--transform-origin) overflow-hidden rounded-lg p-1 shadow-ring-md",
className
)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuSwitchItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}