An action menu on a trigger with checkbox and radio items, submenus, shortcut hints and destructive rows, built on Radix for full keyboard and typeahead support.
Jordan Rivera
jordan@acme.example
"use client";
import * as React from "react";
import {
CreditCard,
LifeBuoy,
LogOut,
Settings,
UserRound,
Users,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ballmac/dropdown-menu";
export default function DropdownMenuDemo() {
const [notify, setNotify] = React.useState(true);
return (
<div className="flex w-full max-w-sm items-center gap-3 rounded-xl border bg-card p-4 shadow-sm">
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-primary text-sm font-semibold text-primary-foreground">
JR
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">Jordan Rivera</p>
<p className="truncate text-xs text-muted-foreground">
jordan@acme.example
</p>
</div>
<DropdownMenu defaultOpen modal={false}>
<DropdownMenuTrigger className="h-9 rounded-md border px-3 text-sm font-medium outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50">
Account
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuLabel>Signed in as Jordan</DropdownMenuLabel>
<DropdownMenuGroup>
<DropdownMenuItem aria-keyshortcuts="Shift+Meta+P">
<UserRound aria-hidden="true" /> Profile
<DropdownMenuShortcut>⇧⌘P</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
<CreditCard aria-hidden="true" /> Billing
<DropdownMenuShortcut>⌘B</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
<Settings aria-hidden="true" /> Settings
<DropdownMenuShortcut>⌘,</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem checked={notify} onCheckedChange={setNotify}>
Email notifications
</DropdownMenuCheckboxItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Users aria-hidden="true" /> Switch team
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem>Acme Design</DropdownMenuItem>
<DropdownMenuItem>Acme Platform</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem>
<LifeBuoy aria-hidden="true" /> Support
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem destructive>
<LogOut aria-hidden="true" /> Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}Installation
$ pnpm dlx shadcn@latest add @ballmac/dropdown-menuInstall the dependencies.
$ pnpm add radix-ui lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/directionCopy the source into your project.
components/ballmac/dropdown-menu.tsx// Ballmac UI: Dropdown Menu. https://ui.ballmac.com/components/dropdown-menu // Based on shadcn/ui Dropdown Menu (MIT, Copyright (c) 2023 shadcn), restyled with tokens, destructive and inset items, shortcut and description slots. "use client"; import * as React from "react"; import { Check, ChevronRight, Circle } from "lucide-react"; import { DropdownMenu as Primitive } from "radix-ui"; import { useDirection } from "@/lib/ballmac/direction"; import { cn } from "@/lib/utils"; const itemBase = "relative flex min-h-8 cursor-default select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground"; const surface = "z-50 min-w-[12rem] overflow-hidden rounded-xl border bg-popover p-1 text-popover-foreground shadow-[0_12px_36px_-10px_rgb(0_0_0/0.25)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 motion-reduce:animate-none"; type DropdownMenuProps = React.ComponentProps<typeof Primitive.Root>; function DropdownMenu(props: DropdownMenuProps) { const dir = useDirection(props.dir); return <Primitive.Root data-slot="dropdown-menu" dir={dir} {...props} />; } type DropdownMenuTriggerProps = React.ComponentProps<typeof Primitive.Trigger>; function DropdownMenuTrigger(props: DropdownMenuTriggerProps) { return <Primitive.Trigger data-slot="dropdown-menu-trigger" {...props} />; } type DropdownMenuGroupProps = React.ComponentProps<typeof Primitive.Group>; function DropdownMenuGroup(props: DropdownMenuGroupProps) { return <Primitive.Group data-slot="dropdown-menu-group" {...props} />; } type DropdownMenuSubProps = React.ComponentProps<typeof Primitive.Sub>; function DropdownMenuSub(props: DropdownMenuSubProps) { return <Primitive.Sub data-slot="dropdown-menu-sub" {...props} />; } type DropdownMenuRadioGroupProps = React.ComponentProps< typeof Primitive.RadioGroup >; function DropdownMenuRadioGroup(props: DropdownMenuRadioGroupProps) { return ( <Primitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} /> ); } type DropdownMenuContentProps = React.ComponentProps<typeof Primitive.Content>; function DropdownMenuContent({ className, sideOffset = 6, collisionPadding = 12, ...props }: DropdownMenuContentProps) { return ( <Primitive.Portal> <Primitive.Content data-slot="dropdown-menu-content" sideOffset={sideOffset} collisionPadding={collisionPadding} className={cn( surface, "max-h-(--radix-dropdown-menu-content-available-height) origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto", className, )} {...props} /> </Primitive.Portal> ); } type DropdownMenuItemProps = React.ComponentProps<typeof Primitive.Item> & { /** Indent the item so it lines up with items that show a check or icon. */ inset?: boolean; /** Style the item as a destructive action (delete, sign out). */ destructive?: boolean; }; function DropdownMenuItem({ className, inset, destructive, ...props }: DropdownMenuItemProps) { return ( <Primitive.Item data-slot="dropdown-menu-item" data-inset={inset ? "" : undefined} data-variant={destructive ? "destructive" : "default"} className={cn( itemBase, "data-[inset]:ps-8", "data-[variant=destructive]:text-destructive data-[variant=destructive]:data-[highlighted]:bg-destructive/10 data-[variant=destructive]:data-[highlighted]:text-destructive data-[variant=destructive]:[&_svg]:!text-destructive", className, )} {...props} /> ); } type DropdownMenuCheckboxItemProps = React.ComponentProps< typeof Primitive.CheckboxItem >; function DropdownMenuCheckboxItem({ className, children, ...props }: DropdownMenuCheckboxItemProps) { return ( <Primitive.CheckboxItem data-slot="dropdown-menu-checkbox-item" className={cn(itemBase, "ps-8", className)} {...props} > <span className="pointer-events-none absolute start-2 flex size-4 items-center justify-center"> <Primitive.ItemIndicator> <Check aria-hidden="true" className="size-4 !text-foreground" /> </Primitive.ItemIndicator> </span> {children} </Primitive.CheckboxItem> ); } type DropdownMenuRadioItemProps = React.ComponentProps< typeof Primitive.RadioItem >; function DropdownMenuRadioItem({ className, children, ...props }: DropdownMenuRadioItemProps) { return ( <Primitive.RadioItem data-slot="dropdown-menu-radio-item" className={cn(itemBase, "ps-8", className)} {...props} > <span className="pointer-events-none absolute start-2 flex size-4 items-center justify-center"> <Primitive.ItemIndicator> <Circle aria-hidden="true" className="size-2 fill-current !text-foreground" /> </Primitive.ItemIndicator> </span> {children} </Primitive.RadioItem> ); } type DropdownMenuLabelProps = React.ComponentProps<typeof Primitive.Label> & { /** Indent the label to match inset items. */ inset?: boolean; }; function DropdownMenuLabel({ className, inset, ...props }: DropdownMenuLabelProps) { return ( <Primitive.Label data-slot="dropdown-menu-label" data-inset={inset ? "" : undefined} className={cn( "px-2 py-1.5 text-xs font-medium text-muted-foreground data-[inset]:ps-8", className, )} {...props} /> ); } type DropdownMenuSeparatorProps = React.ComponentProps< typeof Primitive.Separator >; function DropdownMenuSeparator({ className, ...props }: DropdownMenuSeparatorProps) { return ( <Primitive.Separator data-slot="dropdown-menu-separator" className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} /> ); } type DropdownMenuShortcutProps = React.ComponentProps<"span">; /** Right-aligned keyboard hint. Decorative: the accessible name stays the item text, so add `aria-keyshortcuts` on the item when the shortcut is real. */ function DropdownMenuShortcut({ className, ...props }: DropdownMenuShortcutProps) { return ( <span data-slot="dropdown-menu-shortcut" aria-hidden="true" className={cn( "ms-auto ps-4 font-mono text-xs tracking-wide text-muted-foreground", className, )} {...props} /> ); } type DropdownMenuSubTriggerProps = React.ComponentProps< typeof Primitive.SubTrigger > & { /** Indent the trigger to match inset items. */ inset?: boolean; }; function DropdownMenuSubTrigger({ className, inset, children, ...props }: DropdownMenuSubTriggerProps) { return ( <Primitive.SubTrigger data-slot="dropdown-menu-sub-trigger" data-inset={inset ? "" : undefined} className={cn( itemBase, "data-[inset]:ps-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", className, )} {...props} > {children} <ChevronRight aria-hidden="true" className="ms-auto size-4 rtl:rotate-180" /> </Primitive.SubTrigger> ); } type DropdownMenuSubContentProps = React.ComponentProps< typeof Primitive.SubContent >; function DropdownMenuSubContent({ className, collisionPadding = 12, ...props }: DropdownMenuSubContentProps) { return ( <Primitive.Portal> <Primitive.SubContent data-slot="dropdown-menu-sub-content" collisionPadding={collisionPadding} className={cn( surface, "origin-(--radix-dropdown-menu-content-transform-origin)", className, )} {...props} /> </Primitive.Portal> ); } export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, type DropdownMenuProps, type DropdownMenuTriggerProps, type DropdownMenuContentProps, type DropdownMenuGroupProps, type DropdownMenuItemProps, type DropdownMenuCheckboxItemProps, type DropdownMenuRadioGroupProps, type DropdownMenuRadioItemProps, type DropdownMenuLabelProps, type DropdownMenuSeparatorProps, type DropdownMenuShortcutProps, type DropdownMenuSubProps, type DropdownMenuSubTriggerProps, type DropdownMenuSubContentProps, };Update the import paths to match your project setup.
Usage
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent } from "@/components/ballmac/dropdown-menu"The full example is in the Code tab above.
Examples
Radio choices
By last modified, grid
"use client";
import * as React from "react";
import { ArrowDownAZ, Clock, LayoutGrid, List } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ballmac/dropdown-menu";
export default function DropdownMenuStates() {
const [sort, setSort] = React.useState("modified");
const [view, setView] = React.useState("grid");
return (
<div className="flex w-full max-w-sm flex-wrap items-center gap-3">
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex h-9 items-center gap-2 rounded-md border px-3 text-sm font-medium outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50">
<ArrowDownAZ aria-hidden="true" className="size-4" /> Sort and view
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
<DropdownMenuRadioGroup value={sort} onValueChange={setSort}>
<DropdownMenuRadioItem value="name">Name</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="modified">
<Clock aria-hidden="true" /> Last modified
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel>Layout</DropdownMenuLabel>
<DropdownMenuRadioGroup value={view} onValueChange={setView}>
<DropdownMenuRadioItem value="grid">
<LayoutGrid aria-hidden="true" /> Grid
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="list">
<List aria-hidden="true" /> List
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<p className="text-sm text-muted-foreground" aria-live="polite">
{sort === "name" ? "By name" : "By last modified"}, {view}
</p>
</div>
);
}API reference
<DropdownMenuItem>
| Prop | Type | Default |
|---|---|---|
insetIndent the item so it lines up with items that show a check or icon. | boolean | — |
destructiveStyle the item as a destructive action (delete, sign out). | boolean | — |
<DropdownMenuLabel>
| Prop | Type | Default |
|---|---|---|
insetIndent the label to match inset items. | boolean | — |
<DropdownMenuSubTrigger>
| Prop | Type | Default |
|---|---|---|
insetIndent the trigger to match inset items. | boolean | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Enter / Space / ArrowDown | Opens the menu from the trigger |
| ArrowUp / ArrowDown | Moves between items; Home and End jump |
| Type a letter | Jumps to the next item starting with it |
| ArrowRight / ArrowLeft | Opens and closes a submenu |
| Escape | Closes and returns focus to the trigger |
Use with AI
Opens a menu of actions from a button. Supports groups, labels, checkbox and radio items, nested submenus, shortcuts and a destructive style. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Dropdown Menu (@ballmac/dropdown-menu) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Account, row and overflow (…) actions
- Sort and view preferences with radio items
- Menus with a submenu or checked options
Not for
- Choosing a form value; use select or combobox
- Right-click menus; use context-menu
- A persistent app menu bar; use menubar
Registry JSON: https://ui.ballmac.com/r/dropdown-menu.json
Credits
Based on shadcn/ui Dropdown Menu, adapted by Ballmac. Free to use in personal and commercial projects.
- Registry
- @ballmac/directionshadcn/utils
Pairs well with
Button
A button with six variants, three sizes, a pill shape and a built-in loading state. buttonVariants() styles links the same way.
Kbd
Keyboard key and shortcut display built on the semantic <kbd> element, in three sizes, with KbdGroup for combinations like ⌘ ⇧ K. Adapts inside tooltips.
Accordion
Vertically stacked disclosure sections on Radix Accordion, single or multiple open, with hairline dividers, a rotating plus and height animation.
Avatar
A Radix avatar with image and initials fallback, three sizes, an optional presence dot, and AvatarGroup for overlapping stacks with a +N overflow counter.