@cf/dropdown-menu
Dropdown Menu
A list of actions or toggles opened from a trigger, with groups, labels, submenus, checkbox and radio items.
The radio tick sits on the right and the checkbox tick on the left, a split I haven't resolved yet (more on that below).
Base UI's Menu under shadcn's names. Rows are 16px text with extra padding below md, the same as select items, so they're tappable without a separate mobile menu.
The two indicators sit on different sides and that's not fully resolved. The radio tick is on the right, where an item's state belongs, and it's a tick rather than a dot because a dot at that size reads as a stray mark. The checkbox tick is still on the left with a pl-8 inset. Both carry data-slot="menu-item-indicator" so a row that shows its state another way, like the notes visibility rows with a Switch, can hide the tick.
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
<DropdownMenu>
<DropdownMenuTrigger render={<Button aria-label="Note actions" size="icon-sm" variant="ghost" />}>
<DotsThreeIcon data-icon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuItem onClick={rename}>Rename</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => setDeleting(true)} variant="destructive">
Delete
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>Props
| Prop | Type | Default |
|---|---|---|
| DropdownMenuContent align | start | center | end | start |
| DropdownMenuContent side | top | right | bottom | left | inline-start | inline-end | bottom |
| DropdownMenuContent sideOffset | number | 4 |
| DropdownMenuItem variant | default | destructive | default |
| inset | boolean (Item, Label, SubTrigger) | false |
Rules
The same rules ship in DESIGN.md and the MCP server, for the agents building with this.
- must
Mark delete and discard items with
variant="destructive"onDropdownMenuItem, and put them in their ownDropdownMenuGroupafter aDropdownMenuSeparator.destructive-item-variant
- should
Open an
AlertDialogfrom a destructive item when the action can't be undone, rather than acting on the click.confirm-irreversible
- must
Put
DropdownMenuLabelinside aDropdownMenuGroup; it's Base UI's group label and needs a group to belong to.labels-inside-groups
- should
Hide
DropdownMenuShortcutbelowmd(hidden md:inline) when you use it; the component doesn't do that for you.Shortcut hints are keyboard affordances, and the site removes those on touch screens.
shortcuts-hidden-on-touch
- should
Use
Selectfor picking a value that the trigger should then display, and keep the menu for actions and toggles.select-for-values
Install
bunx shadcn@latest add @cf/dropdown-menuNeeds the @cf registry in your components.json once. The theme and any sibling components come along automatically.
Source
dropdown-menu.tsxShowHide
"use client";
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
import { CaretRightIcon, CheckIcon } from "@phosphor-icons/react";
import { cn } from "cn";
import type * as React from "react";
function DropdownMenu(props: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root {...props} />;
}
function DropdownMenuPortal(props: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal {...props} />;
}
function DropdownMenuTrigger(props: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger {...props} />;
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
align={align}
alignOffset={alignOffset}
className="isolate z-50"
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
className={cn(
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:fade-in-0 data-open:zoom-in-95 data-closed:fade-out-0 data-closed:zoom-out-95 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-closed:animate-out data-open:animate-in",
className
)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
);
}
function DropdownMenuGroup(props: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group {...props} />;
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & { inset?: boolean }) {
return (
<MenuPrimitive.GroupLabel
className={cn(
"px-2 py-1.5 font-semibold text-sm",
inset && "pl-8",
className
)}
{...props}
/>
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<MenuPrimitive.Item
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-2.5 text-base outline-none transition-colors data-disabled:pointer-events-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:opacity-50 md:py-1.5 md:text-sm",
variant === "destructive" &&
"text-destructive data-highlighted:bg-destructive/10 data-highlighted:text-destructive",
inset && "pl-8",
className
)}
{...props}
/>
);
}
function DropdownMenuSub(props: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & { inset?: boolean }) {
return (
<MenuPrimitive.SubmenuTrigger
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-2.5 text-base outline-none data-highlighted:bg-accent data-popup-open:bg-accent md:py-1.5 md:text-sm",
inset && "pl-8",
className
)}
{...props}
>
{children}
<CaretRightIcon className="ml-auto size-4" />
</MenuPrimitive.SubmenuTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
className={cn("min-w-[8rem] shadow-lg", className)}
side="right"
sideOffset={0}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: MenuPrimitive.CheckboxItem.Props) {
return (
<MenuPrimitive.CheckboxItem
checked={checked}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-2.5 pr-2 pl-8 text-base outline-none transition-colors data-disabled:pointer-events-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:opacity-50 md:py-1.5 md:text-sm",
className
)}
{...props}
>
<MenuPrimitive.CheckboxItemIndicator
render={
// Named so an item that shows its state another way — a switch, say
// — can hide the tick without guessing at the markup.
<span
className="absolute left-2 flex size-3.5 items-center justify-center"
data-slot="menu-item-indicator"
/>
}
>
<CheckIcon className="size-4" />
</MenuPrimitive.CheckboxItemIndicator>
{children}
</MenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup(props: MenuPrimitive.RadioGroup.Props) {
return <MenuPrimitive.RadioGroup {...props} />;
}
function DropdownMenuRadioItem({
className,
children,
...props
}: MenuPrimitive.RadioItem.Props) {
return (
<MenuPrimitive.RadioItem
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-2.5 pr-8 pl-2 text-base outline-none transition-colors data-disabled:pointer-events-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:opacity-50 md:py-1.5 md:text-sm",
className
)}
{...props}
>
<MenuPrimitive.RadioItemIndicator
render={
// On the right, where an item's state belongs: the left is where
// every row starts, ticked or not.
<span
className="absolute right-2 flex size-3.5 items-center justify-center"
data-slot="menu-item-indicator"
/>
}
>
{/* A tick, not a dot: at this size a dot reads as a stray mark, and
the answer to "which of these is it" is the same either way. */}
<CheckIcon className="size-4" />
</MenuPrimitive.RadioItemIndicator>
{children}
</MenuPrimitive.RadioItem>
);
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
};