rapidUI

Dropdown Menu

Menu triggered by a button, with items, groups, checkboxes, radio items and submenus.

Installation

pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/dropdown-menu.json

Installs to @/components/ui/dropdown-menu.

Manual installation

Install the dependencies:

pnpm add @base-ui/react@^1.8.0 lucide-react@^1.48.0

Copy the source into your project:

components/ui/dropdown-menu.tsx
'use client'

import { Menu as MenuPrimitive } from '@base-ui/react/menu'
import { CheckIcon, ChevronRightIcon } from 'lucide-react'
import * as React from 'react'
import { cn } from '@/lib/utils'

function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
	return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}

function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
	return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}

function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
	return <MenuPrimitive.Trigger data-slot="dropdown-menu-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
				className="isolate z-50 outline-none"
				align={align}
				alignOffset={alignOffset}
				side={side}
				sideOffset={sideOffset}
			>
				<MenuPrimitive.Popup
					data-slot="dropdown-menu-content"
					className={cn(
						'z-50 max-h-(--available-height) w-max max-w-(--available-width) min-w-[max(8rem,var(--anchor-width))] origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-border duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95',
						className,
					)}
					{...props}
				/>
			</MenuPrimitive.Positioner>
		</MenuPrimitive.Portal>
	)
}

function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
	return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}

function DropdownMenuLabel({
	className,
	inset,
	...props
}: MenuPrimitive.GroupLabel.Props & {
	inset?: boolean
}) {
	return (
		<MenuPrimitive.GroupLabel
			data-slot="dropdown-menu-label"
			data-inset={inset}
			className={cn('px-1.5 py-1 text-xs font-medium data-inset:pl-7', className)}
			{...props}
		/>
	)
}

function DropdownMenuItem({
	className,
	inset,
	variant = 'default',
	...props
}: MenuPrimitive.Item.Props & {
	inset?: boolean
	variant?: 'default' | 'destructive'
}) {
	return (
		<MenuPrimitive.Item
			data-slot="dropdown-menu-item"
			data-inset={inset}
			data-variant={variant}
			className={cn(
				"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:*:[svg]:!text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:!text-destructive",
				className,
			)}
			{...props}
		/>
	)
}

function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
	return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}

function DropdownMenuSubTrigger({
	className,
	inset,
	children,
	...props
}: MenuPrimitive.SubmenuTrigger.Props & {
	inset?: boolean
}) {
	return (
		<MenuPrimitive.SubmenuTrigger
			data-slot="dropdown-menu-sub-trigger"
			data-inset={inset}
			className={cn(
				"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
				className,
			)}
			{...props}
		>
			{children}
			<ChevronRightIcon className="ml-auto" aria-hidden="true" />
		</MenuPrimitive.SubmenuTrigger>
	)
}

function DropdownMenuSubContent({
	align = 'start',
	alignOffset = -3,
	side = 'right',
	sideOffset = 0,
	className,
	...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
	return (
		<DropdownMenuContent
			data-slot="dropdown-menu-sub-content"
			className={cn(
				'w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-border duration-100 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:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
				className,
			)}
			align={align}
			alignOffset={alignOffset}
			side={side}
			sideOffset={sideOffset}
			{...props}
		/>
	)
}

function DropdownMenuCheckboxItem({
	className,
	children,
	checked,
	inset,
	...props
}: MenuPrimitive.CheckboxItem.Props & {
	inset?: boolean
}) {
	return (
		<MenuPrimitive.CheckboxItem
			data-slot="dropdown-menu-checkbox-item"
			data-inset={inset}
			className={cn(
				"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
				className,
			)}
			checked={checked}
			{...props}
		>
			<span
				className="pointer-events-none absolute right-2 flex items-center justify-center"
				data-slot="dropdown-menu-checkbox-item-indicator"
			>
				<MenuPrimitive.CheckboxItemIndicator>
					<CheckIcon aria-hidden="true" />
				</MenuPrimitive.CheckboxItemIndicator>
			</span>
			{children}
		</MenuPrimitive.CheckboxItem>
	)
}

function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
	return <MenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />
}

function DropdownMenuRadioItem({
	className,
	children,
	inset,
	...props
}: MenuPrimitive.RadioItem.Props & {
	inset?: boolean
}) {
	return (
		<MenuPrimitive.RadioItem
			data-slot="dropdown-menu-radio-item"
			data-inset={inset}
			className={cn(
				"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
				className,
			)}
			{...props}
		>
			<span
				className="pointer-events-none absolute right-2 flex items-center justify-center"
				data-slot="dropdown-menu-radio-item-indicator"
			>
				<MenuPrimitive.RadioItemIndicator>
					<CheckIcon aria-hidden="true" />
				</MenuPrimitive.RadioItemIndicator>
			</span>
			{children}
		</MenuPrimitive.RadioItem>
	)
}

function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
	return (
		<MenuPrimitive.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('ml-auto text-xs tracking-widest text-muted-foreground', className)}
			{...props}
		/>
	)
}

export {
	DropdownMenu,
	DropdownMenuPortal,
	DropdownMenuTrigger,
	DropdownMenuContent,
	DropdownMenuGroup,
	DropdownMenuLabel,
	DropdownMenuItem,
	DropdownMenuCheckboxItem,
	DropdownMenuRadioGroup,
	DropdownMenuRadioItem,
	DropdownMenuSeparator,
	DropdownMenuShortcut,
	DropdownMenuSub,
	DropdownMenuSubTrigger,
	DropdownMenuSubContent,
}

A dropdown menu component that displays a menu when triggered by a button. Built on top of Base UI Menu, it follows WAI-ARIA design patterns and provides a fully accessible dropdown menu experience.

Features

  • Keyboard navigation
  • Type-ahead support
  • Screen reader support
  • Submenu support
  • Checkbox items
  • Radio group items
  • Keyboard shortcuts (DropdownMenuShortcut)
  • Destructive item variant (variant="destructive")
  • Inset items, labels, checkbox and radio items (inset)
  • Icons are sized automatically (size-4), no extra classes needed
  • Content is as wide as its longest item, never narrower than the trigger or 8rem and never wider than the space available, and aligns to start

Usage

import {
  DropdownMenu,
  DropdownMenuTrigger,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuShortcut,
  DropdownMenuGroup,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuCheckboxItem,
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { SettingsIcon, Trash2Icon, UserIcon } from 'lucide-react'

// Basic usage
export function BasicDropdownMenu() {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger render={<Button variant="ghost" />}>Ingrid Hansen</DropdownMenuTrigger>
      <DropdownMenuContent className="w-60">
        <DropdownMenuGroup>
          <DropdownMenuLabel>My account</DropdownMenuLabel>
          <DropdownMenuItem>
            <UserIcon />
            My profile
            <DropdownMenuShortcut>⇧⌘P</DropdownMenuShortcut>
          </DropdownMenuItem>
          <DropdownMenuItem>
            <SettingsIcon />
            Settings
            <DropdownMenuShortcut>⌘,</DropdownMenuShortcut>
          </DropdownMenuItem>
        </DropdownMenuGroup>
        <DropdownMenuSeparator />
        <DropdownMenuItem variant="destructive">
          <Trash2Icon />
          Remove delegation
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

// With submenu
export function DropdownMenuWithSubmenu() {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger render={<Button />}>New</DropdownMenuTrigger>
      <DropdownMenuContent>
        <DropdownMenuItem>Case</DropdownMenuItem>
        <DropdownMenuSub>
          <DropdownMenuSubTrigger>Document from template</DropdownMenuSubTrigger>
          <DropdownMenuSubContent>
            <DropdownMenuItem>Decision to grant</DropdownMenuItem>
            <DropdownMenuItem>Neighbour notice</DropdownMenuItem>
          </DropdownMenuSubContent>
        </DropdownMenuSub>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

// With checkboxes
export function DropdownMenuWithCheckboxes() {
  const [checked, setChecked] = React.useState(true)

  return (
    <DropdownMenu>
      <DropdownMenuTrigger render={<Button variant="outline" />}>Columns</DropdownMenuTrigger>
      <DropdownMenuContent>
        <DropdownMenuCheckboxItem
          checked={checked}
          onCheckedChange={setChecked}
        >
          Case officer
        </DropdownMenuCheckboxItem>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

Examples

Basic Dropdown Menu

An account menu for a case officer, with icons and keyboard shortcuts.

With Submenu

A "New" menu with nested submenus for document templates and sharing.

With Checkboxes

Checkbox items that choose which columns the case list shows. Checkbox and radio items keep the menu open by default; pass closeOnClick to close it on selection.

With Radio Items

Radio items for the sort order. The trigger shows the current choice.

Destructive and Disabled Items

Use variant="destructive" for dangerous actions and disabled to make an item non-interactive. Here the menu sits in an Item for a document.

Content Width

Without a width class the menu is sized by its content and is at least as wide as the trigger. A menu on a wide trigger lines up with it, and a menu opened from an icon button grows to fit its labels instead of wrapping them. Set a width with className, such as w-56, when you want a fixed size.

Inset

Use inset to align items without an icon with items that have one.

Disabled

A dropdown menu showing disabled states of item, radio item, checkbox item and item in submenu.

Props

  • defaultOpen - The open state when initially rendered
  • open - The controlled open state
  • onOpenChange - Event handler for open state changes
  • modal - Whether the menu is modal (blocks interactions outside)
  • render - Render the trigger as another element, e.g. render={<Button variant="outline" />}
  • children - The content of the trigger
  • children - Content to display in the menu
  • className - Additional CSS classes (the content is sized by its items and is at least as wide as the trigger; set a fixed width with e.g. w-56)
  • sideOffset - Distance from trigger (default 4)
  • align - Alignment relative to trigger (default 'start')
  • side - Side of the trigger to open on (default 'bottom')
  • variant - 'default' | 'destructive' (default 'default')
  • inset - Whether to indent the item
  • disabled - Whether the item is disabled
  • onClick - Event handler for selection
  • closeOnClick - Whether clicking the item closes the menu (default true)
  • children - Item content
  • checked - The checked state (a check icon is shown on the right)
  • onCheckedChange - Event handler for checked state changes
  • closeOnClick - Whether clicking the item closes the menu (default false)
  • inset - Whether to indent the item
  • disabled - Whether the item is disabled
  • children - Item content
  • value - The value of the radio item (the selected item shows a check icon on the right)
  • inset - Whether to indent the item
  • disabled - Whether the item is disabled
  • children - Item content
  • inset - Whether to indent the label
  • children - Label content

A label names its group, so it must be placed inside a DropdownMenuGroup or DropdownMenuRadioGroup.

  • inset - Whether to indent the sub trigger
  • children - Trigger content (a chevron is appended automatically)
  • className - Additional CSS classes
  • children - Shortcut text
  • className - Additional CSS classes

Accessibility

The DropdownMenu component follows WAI-ARIA guidelines:

  • Uses proper ARIA roles (menu, menuitem, menuitemcheckbox, menuitemradio)
  • Supports keyboard navigation:
    • Space/Enter: Select item
    • ↑/↓: Navigate items
    • →: Open submenu
    • ←: Close submenu
    • Esc: Close menu
  • Announces menu state changes
  • Maintains focus management
  • Provides visual feedback for interaction states

Best Practices

  1. Menu Structure:

    • Group related items
    • Use clear labels
    • Keep items concise
    • Provide helpful shortcuts
  2. Visual Design:

    • Maintain consistent spacing
    • Use clear visual hierarchy
    • Provide feedback for actions
    • Consider mobile interactions
  3. Interaction:

    • Manage focus properly
    • Consider animations
    • Test edge cases
  4. Accessibility:

    • Write descriptive labels
    • Use semantic markup
    • Test with screen readers
    • Verify keyboard navigation
  • Context Menu
  • Menubar
  • Select
  • Command