Dropdown Menu
A keyboard-navigable menu with danger items, checkbox items, radio groups, submenus and shortcuts.
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export default function DropdownMenuDemo() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">Open menu</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48">
<DropdownMenuLabel>My account</DropdownMenuLabel>
<DropdownMenuItem>
Settings <DropdownMenuShortcut>⌘,</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="danger">Sign out</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}Installation
Run the following command
npx shadcn@latest add @ai2/dropdown-menuDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install radix-ui@^1.6.1 lucide-react@^1.23.0 tw-animate-css@^1.4.0Add the cn util
lib/utils.tsimport { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/* Adds a source-attribution ref param to a URL (the inspiration exports mark their
outbound links with an ai2.design attribution). An invalid URL is returned as is.
This file is SHOWN TO THE CONSUMER: the docs component pages render the source of
`cn` in a code block, so a Turkish comment here would reach every one of those
pages. Keep it English. */
export function withRef(url: string, ref = "ai2.design"): string {
try {
const u = new URL(url)
u.searchParams.set("ref", ref)
return u.toString()
} catch {
return url
}
}Copy the source code
components/ui/dropdown-menu.tsx"use client"
import type * as React from "react"
import { Check, ChevronRight, Circle } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "@/components/ui/primitives"
import { cn } from "@/lib/utils"
function DropdownMenu(props: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuTrigger(props: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuGroup(props: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuContent({
className,
sideOffset = 6,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 max-w-[calc(100vw-2rem)] max-h-(--radix-dropdown-menu-content-available-height) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 motion-reduce:animate-none",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
const itemClass =
"relative flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none transition-colors duration-(--motion-fast) focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:shrink-0 [&_i]:text-base [&_i]:leading-none"
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "danger"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-variant={variant}
className={cn(
itemClass,
inset && "ps-8",
variant === "danger" && "text-danger focus:bg-danger-soft focus:text-danger-soft-foreground [&_svg]:text-danger [&_i]:text-danger",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(itemClass, "ps-8", className)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex size-4 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup(props: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(itemClass, "ps-8", className)}
{...props}
>
<span className="absolute left-2 flex size-4 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
className={cn("px-2 py-1.5 text-xs font-medium text-muted-foreground", inset && "ps-8", className)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn("ms-auto text-xs tracking-widest text-muted-foreground", className)}
{...props}
/>
)
}
function DropdownMenuSub(props: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
className={cn(itemClass, "data-[state=open]:bg-accent", inset && "ps-8", className)}
{...props}
>
{children}
<ChevronRight className="ms-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 max-w-[calc(100vw-2rem)] min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 motion-reduce:animate-none",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Usage
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">Open menu</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>Settings</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="danger">Sign out</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>Wrap any element with DropdownMenuTrigger asChild to make it the trigger. The menu portals to the body, animates from the trigger side and is fully keyboard navigable.
Examples
Item variants and states
Both item variants in one menu: default for plain actions and variant="danger" for destructive ones, plus the inset and disabled states and a right-aligned DropdownMenuShortcut.
Checkbox items
Control the checkmark with checked and onCheckedChange from radix.
Radio group
Submenu
Props
All parts forward their radix DropdownMenu props: onSelect and disabled on items, open / onOpenChange on the root. The ai2-specific props:
DropdownMenuItem props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "danger" | "default" | danger colors the item (and its icons) with the danger tone for destructive actions. |
inset | boolean | - | Adds left padding so plain items align with checkbox and radio items. Also available on DropdownMenuLabel and DropdownMenuSubTrigger. |
DropdownMenuContent props
| Prop | Type | Default | Description |
|---|---|---|---|
sideOffset | number | 6 | Distance in pixels between the trigger and the menu. |
ai2 Dropdown Menu: a complete action menu for React
The ai2 Dropdown Menu is a shadcn-compatible dropdown menu component for React, built on the radix-ui DropdownMenu primitive and styled with Tailwind CSS v4. It covers the whole menu vocabulary in one install: plain actions, a danger variant for destructive items, checkbox items, radio groups, nested submenus, labels, separators and right-aligned shortcut hints.
Because it ships through the shadcn registry format, you install it with one CLI command, an MCP agent, or a copy-paste, and the source lands in your own project. You own the file; there is no runtime dependency on ai2 itself. The live example above is the exact component you get.
What is the ai2 Dropdown Menu?
It is a fourteen-part composition around DropdownMenu, DropdownMenuTrigger and DropdownMenuContent, with DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup and DropdownMenuRadioItem for entries, plus label, separator, shortcut and the DropdownMenuSub trio for nesting. The anatomy matches shadcn/ui exactly, so existing snippets and AI agents keep working.
On top of the radix API, items add two ai2 props: variant, where danger colors the item and its icons with the danger tone, and inset, which aligns plain items with checkbox and radio rows. The content portals to the body, animates from the trigger side and caps its height to the available viewport space.
Why use it
- Accessible by construction: The radix-ui DropdownMenu primitive handles menu roles, typeahead, focus management and dismissal: arrow keys move, Enter selects, Escape closes and focus returns to the trigger.
- Danger items built in: variant="danger" styles destructive actions like Delete or Sign out with the danger token, including their icons and a soft danger highlight on focus, no custom classes needed.
- Stateful items included: Checkbox items with check indicators and exclusive radio groups with dot indicators ship in the same file, so view toggles and position pickers need no extra components.
- Submenus that behave: DropdownMenuSub gives hover and keyboard-openable nested menus with the correct aria wiring and a chevron indicator, which hand-rolled menus rarely get right.
- Agent-readable metadata: The registry item describes its parts and intended use in plain words, so an MCP agent can find, inspect and install it without guessing.
Features
- shadcn registry install: One command adds the component, its dependencies and the @ai2/tokens theme to your project.
- Viewport-aware content: The menu caps its height to the radix available-height variable and scrolls internally, so long menus never run off screen.
- Origin-aware animation: Open and close fade and zoom from the trigger side using the radix transform-origin variable, and item highlights run on the --motion-fast token.
- Labels, separators and shortcuts: DropdownMenuLabel titles a section, DropdownMenuSeparator splits groups, and DropdownMenuShortcut right-aligns a keyboard hint inside any item.
- Inset alignment: The inset prop on items, labels and sub-triggers adds left padding so mixed menus with and without indicators stay on one text edge.
- Data attributes for styling: Every part exposes data-slot, items expose data-variant, and radix adds data-state and data-disabled, so any state is targetable from CSS.
Production tips
- Use asChild on the trigger: DropdownMenuTrigger asChild turns your own Button or icon button into the trigger without nesting two buttons in the DOM.
- Put danger items last: Separate destructive actions with a DropdownMenuSeparator and keep them at the bottom with variant="danger", so muscle memory never lands on Delete.
- Menus act, they do not navigate forms: Use onSelect for actions. For picking a value in a form, an ai2 Select or Combobox is the right control; radio items fit view preferences, not form data.
- Keep submenus shallow: One level of nesting is scannable; two is a maze. If a submenu grows past roughly seven items, promote it to its own dialog or page.
- Mind checkbox item dismissal: By default the menu closes when a checkbox item is toggled. For multi-toggle panels, call event.preventDefault() in onSelect to keep it open.
Works with the rest of ai2
The classic recipe is an ai2 Button trigger, an ai2 Avatar for account menus, and ai2 Kbd caps inside shortcut hints. In data tables, pair it with the ai2 Table for per-row action menus.
Choose the right overlay for the job: the ai2 Popover hosts arbitrary content rather than menu items, and the ai2 Dialog handles the confirmation a danger item usually leads to. All of them share one token source and the same popover surface styling.