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
A list of actions off a button. The trigger takes your own element through render, and variant="destructive" tints the one item people should slow down on.
Switch items
DropdownMenuSwitchItem is for a setting the menu toggles and keeps showing, so it does not close on click; DropdownMenuCheckboxItem is for a choice the menu records and then dismisses. The switch is decorative, so the row keeps one name and one hit target. Not for theme unless the app has no system.
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" | "start" | 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 { IconCheck as CheckIcon, IconChevronRight as ChevronRightIcon } from "@tabler/icons-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)
// @use-when a menu of actions opened from a trigger.
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
// positionMethod="fixed": Base UI's Positioner defaults to
// "absolute", which positions the portaled popup in DOCUMENT
// coordinates. On open, Base UI moves focus into the popup before
// Floating UI has computed the transform, so the browser scrolls the
// focused element into view while it is still sitting at the document
// origin, and the whole page jumps to the top. With "fixed" the
// pre-position origin is the viewport, so there is nothing to scroll
// to. It also keeps the popup correct inside a scrolling or
// transformed ancestor.
positionMethod="fixed"
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-800 data-[highlighted]:text-accent dark:data-[highlighted]:text-accent-400 data-[highlighted]:[&_svg:not([class*='text-'])]:text-accent dark:data-[highlighted]:[&_svg:not([class*='text-'])]:text-accent-400 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 dark:data-[variant=destructive]:data-[highlighted]:text-destructive data-[variant=destructive]:data-[highlighted]:[&_svg:not([class*='text-'])]:text-destructive dark: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-800 data-[highlighted]:text-accent dark:data-[highlighted]:text-accent-400 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, 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.
*
* THEME IS THE TEMPTING WRONG CASE. A "Dark mode" switch reads well and this
* doc used to call it the canonical one, but a switch is a boolean and a theme
* preference usually is not: the moment someone flips it the value is a side,
* and no row is left that hands the choice back to the OS. Reach for it only
* where the app stores light and dark and never asks the OS at all. Where
* `system` exists, use a `DropdownMenuSub` holding a radio group of Light,
* Dark and System, which can name the third choice out loud.
*
* 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-800 data-[highlighted]:text-accent dark:data-[highlighted]:text-accent-400 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-800 data-[highlighted]:text-accent dark:data-[highlighted]:text-accent-400 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-xs 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-800 data-[highlighted]:text-accent dark:data-[highlighted]:text-accent-400 data-[popup-open]:bg-accent-100 dark:data-[popup-open]:bg-accent-800 data-[popup-open]:text-accent dark:data-[popup-open]:text-accent-400 [&_svg:not([class*='text-'])]:text-muted-foreground data-[highlighted]:[&_svg:not([class*='text-'])]:text-accent dark:data-[highlighted]:[&_svg:not([class*='text-'])]:text-accent-400 data-[popup-open]:[&_svg:not([class*='text-'])]:text-accent dark:data-[popup-open]:[&_svg:not([class*='text-'])]:text-accent-400 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
// positionMethod="fixed": Base UI's Positioner defaults to
// "absolute", which positions the portaled popup in DOCUMENT
// coordinates. On open, Base UI moves focus into the popup before
// Floating UI has computed the transform, so the browser scrolls the
// focused element into view while it is still sitting at the document
// origin, and the whole page jumps to the top. With "fixed" the
// pre-position origin is the viewport, so there is nothing to scroll
// to. It also keeps the popup correct inside a scrolling or
// transformed ancestor.
positionMethod="fixed"
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,
}