Dropdown Patterns
Build accessible dropdown menus, comboboxes, and multi-select components with Radix UI.
Overview#
Dropdowns are essential for navigation and selection interfaces. This pattern covers:
- Basic dropdown menus
- User account menus
- Action menus with icons
- Searchable combobox
- Multi-select dropdowns
Prerequisites#
npm install @radix-ui/react-dropdown-menu @radix-ui/react-popover lucide-reactBasic Dropdown Menu#
A simple dropdown menu with Radix UI.
1// components/ui/DropdownMenu.tsx
2'use client'
3
4import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
5import { forwardRef } from 'react'
6import { cn } from '@/lib/utils'
7
8export const DropdownMenu = DropdownMenuPrimitive.Root
9export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
10
11export const DropdownMenuContent = forwardRef<
12 React.ElementRef<typeof DropdownMenuPrimitive.Content>,
13 React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
14>(({ className, sideOffset = 4, ...props }, ref) => (
15 <DropdownMenuPrimitive.Portal>
16 <DropdownMenuPrimitive.Content
17 ref={ref}
18 sideOffset={sideOffset}
19 className={cn(
20 'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-white p-1 shadow-md',
21 'data-[state=open]:animate-in data-[state=closed]:animate-out',
22 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
23 'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
24 className
25 )}
26 {...props}
27 />
28 </DropdownMenuPrimitive.Portal>
29))
30DropdownMenuContent.displayName = 'DropdownMenuContent'
31
32export const DropdownMenuItem = forwardRef<
33 React.ElementRef<typeof DropdownMenuPrimitive.Item>,
34 React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
35 inset?: boolean
36 }
37>(({ className, inset, ...props }, ref) => (
38 <DropdownMenuPrimitive.Item
39 ref={ref}
40 className={cn(
41 'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none',
42 'focus:bg-gray-100 focus:text-gray-900',
43 'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
44 inset && 'pl-8',
45 className
46 )}
47 {...props}
48 />
49))
50DropdownMenuItem.displayName = 'DropdownMenuItem'
51
52export const DropdownMenuSeparator = forwardRef<
53 React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
54 React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
55>(({ className, ...props }, ref) => (
56 <DropdownMenuPrimitive.Separator
57 ref={ref}
58 className={cn('-mx-1 my-1 h-px bg-gray-200', className)}
59 {...props}
60 />
61))
62DropdownMenuSeparator.displayName = 'DropdownMenuSeparator'User Menu Dropdown#
A user account menu with profile and settings links.
1// components/UserMenu.tsx
2'use client'
3
4import {
5 DropdownMenu,
6 DropdownMenuContent,
7 DropdownMenuItem,
8 DropdownMenuSeparator,
9 DropdownMenuTrigger
10} from '@/components/ui/DropdownMenu'
11import { signOut, useSession } from 'next-auth/react'
12import { User, Settings, LogOut, CreditCard } from 'lucide-react'
13import Link from 'next/link'
14import Image from 'next/image'
15
16export function UserMenu() {
17 const { data: session } = useSession()
18
19 if (!session?.user) return null
20
21 return (
22 <DropdownMenu>
23 <DropdownMenuTrigger asChild>
24 <button className="flex items-center gap-2 rounded-full focus:outline-none focus:ring-2 focus:ring-blue-500">
25 {session.user.image ? (
26 <Image
27 src={session.user.image}
28 alt={session.user.name ?? ''}
29 width={32}
30 height={32}
31 className="rounded-full"
32 />
33 ) : (
34 <div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200">
35 <User className="h-4 w-4" />
36 </div>
37 )}
38 </button>
39 </DropdownMenuTrigger>
40
41 <DropdownMenuContent align="end" className="w-56">
42 <div className="px-2 py-1.5">
43 <p className="text-sm font-medium">{session.user.name}</p>
44 <p className="text-xs text-gray-500">{session.user.email}</p>
45 </div>
46
47 <DropdownMenuSeparator />
48
49 <DropdownMenuItem asChild>
50 <Link href="/settings/profile" className="flex items-center gap-2">
51 <User className="h-4 w-4" />
52 Profile
53 </Link>
54 </DropdownMenuItem>
55
56 <DropdownMenuItem asChild>
57 <Link href="/settings" className="flex items-center gap-2">
58 <Settings className="h-4 w-4" />
59 Settings
60 </Link>
61 </DropdownMenuItem>
62
63 <DropdownMenuItem asChild>
64 <Link href="/billing" className="flex items-center gap-2">
65 <CreditCard className="h-4 w-4" />
66 Billing
67 </Link>
68 </DropdownMenuItem>
69
70 <DropdownMenuSeparator />
71
72 <DropdownMenuItem
73 onClick={() => signOut()}
74 className="text-red-600 focus:text-red-600"
75 >
76 <LogOut className="mr-2 h-4 w-4" />
77 Sign out
78 </DropdownMenuItem>
79 </DropdownMenuContent>
80 </DropdownMenu>
81 )
82}Combobox / Searchable Select#
A searchable dropdown for selecting from a list of options.
1// components/ui/Combobox.tsx
2'use client'
3
4import { useState } from 'react'
5import { Check, ChevronsUpDown } from 'lucide-react'
6import * as Popover from '@radix-ui/react-popover'
7import { cn } from '@/lib/utils'
8
9interface Option {
10 value: string
11 label: string
12}
13
14interface ComboboxProps {
15 options: Option[]
16 value?: string
17 onChange: (value: string) => void
18 placeholder?: string
19 searchPlaceholder?: string
20 emptyText?: string
21}
22
23export function Combobox({
24 options,
25 value,
26 onChange,
27 placeholder = 'Select option...',
28 searchPlaceholder = 'Search...',
29 emptyText = 'No results found.'
30}: ComboboxProps) {
31 const [open, setOpen] = useState(false)
32 const [search, setSearch] = useState('')
33
34 const filteredOptions = options.filter(option =>
35 option.label.toLowerCase().includes(search.toLowerCase())
36 )
37
38 const selectedOption = options.find(opt => opt.value === value)
39
40 return (
41 <Popover.Root open={open} onOpenChange={setOpen}>
42 <Popover.Trigger asChild>
43 <button
44 role="combobox"
45 aria-expanded={open}
46 className={cn(
47 'flex w-full items-center justify-between rounded-md border px-3 py-2 text-sm',
48 'focus:outline-none focus:ring-2 focus:ring-blue-500',
49 !value && 'text-gray-500'
50 )}
51 >
52 {selectedOption?.label ?? placeholder}
53 <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
54 </button>
55 </Popover.Trigger>
56
57 <Popover.Portal>
58 <Popover.Content
59 className="z-50 w-[--radix-popover-trigger-width] rounded-md border bg-white shadow-md"
60 sideOffset={4}
61 >
62 <div className="p-2">
63 <input
64 type="text"
65 placeholder={searchPlaceholder}
66 value={search}
67 onChange={e => setSearch(e.target.value)}
68 className="w-full rounded border px-2 py-1.5 text-sm focus:outline-none focus:ring-1"
69 />
70 </div>
71
72 <div className="max-h-60 overflow-auto p-1">
73 {filteredOptions.length === 0 ? (
74 <p className="p-2 text-center text-sm text-gray-500">{emptyText}</p>
75 ) : (
76 filteredOptions.map(option => (
77 <button
78 key={option.value}
79 onClick={() => {
80 onChange(option.value)
81 setOpen(false)
82 setSearch('')
83 }}
84 className={cn(
85 'flex w-full items-center rounded-sm px-2 py-1.5 text-sm',
86 'hover:bg-gray-100 focus:bg-gray-100 focus:outline-none',
87 value === option.value && 'bg-gray-100'
88 )}
89 >
90 <Check
91 className={cn(
92 'mr-2 h-4 w-4',
93 value === option.value ? 'opacity-100' : 'opacity-0'
94 )}
95 />
96 {option.label}
97 </button>
98 ))
99 )}
100 </div>
101 </Popover.Content>
102 </Popover.Portal>
103 </Popover.Root>
104 )
105}Multi-Select Dropdown#
Select multiple options with tags display.
1// components/ui/MultiSelect.tsx
2'use client'
3
4import { useState } from 'react'
5import { X, Check, ChevronsUpDown } from 'lucide-react'
6import * as Popover from '@radix-ui/react-popover'
7import { cn } from '@/lib/utils'
8
9interface Option {
10 value: string
11 label: string
12}
13
14interface MultiSelectProps {
15 options: Option[]
16 value: string[]
17 onChange: (value: string[]) => void
18 placeholder?: string
19 maxDisplay?: number
20}
21
22export function MultiSelect({
23 options,
24 value,
25 onChange,
26 placeholder = 'Select options...',
27 maxDisplay = 3
28}: MultiSelectProps) {
29 const [open, setOpen] = useState(false)
30
31 const selectedOptions = options.filter(opt => value.includes(opt.value))
32
33 const toggleOption = (optionValue: string) => {
34 if (value.includes(optionValue)) {
35 onChange(value.filter(v => v !== optionValue))
36 } else {
37 onChange([...value, optionValue])
38 }
39 }
40
41 const removeOption = (optionValue: string) => {
42 onChange(value.filter(v => v !== optionValue))
43 }
44
45 return (
46 <Popover.Root open={open} onOpenChange={setOpen}>
47 <Popover.Trigger asChild>
48 <button
49 className={cn(
50 'flex min-h-[38px] w-full flex-wrap items-center gap-1 rounded-md border px-2 py-1',
51 'focus:outline-none focus:ring-2 focus:ring-blue-500'
52 )}
53 >
54 {selectedOptions.length === 0 ? (
55 <span className="text-sm text-gray-500">{placeholder}</span>
56 ) : (
57 <>
58 {selectedOptions.slice(0, maxDisplay).map(option => (
59 <span
60 key={option.value}
61 className="flex items-center gap-1 rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-800"
62 >
63 {option.label}
64 <button
65 onClick={e => {
66 e.stopPropagation()
67 removeOption(option.value)
68 }}
69 className="hover:text-blue-600"
70 >
71 <X className="h-3 w-3" />
72 </button>
73 </span>
74 ))}
75 {selectedOptions.length > maxDisplay && (
76 <span className="text-xs text-gray-500">
77 +{selectedOptions.length - maxDisplay} more
78 </span>
79 )}
80 </>
81 )}
82 <ChevronsUpDown className="ml-auto h-4 w-4 shrink-0 opacity-50" />
83 </button>
84 </Popover.Trigger>
85
86 <Popover.Portal>
87 <Popover.Content
88 className="z-50 w-[--radix-popover-trigger-width] rounded-md border bg-white p-1 shadow-md"
89 sideOffset={4}
90 >
91 <div className="max-h-60 overflow-auto">
92 {options.map(option => (
93 <button
94 key={option.value}
95 onClick={() => toggleOption(option.value)}
96 className={cn(
97 'flex w-full items-center rounded-sm px-2 py-1.5 text-sm',
98 'hover:bg-gray-100 focus:bg-gray-100 focus:outline-none'
99 )}
100 >
101 <div
102 className={cn(
103 'mr-2 flex h-4 w-4 items-center justify-center rounded border',
104 value.includes(option.value)
105 ? 'border-blue-500 bg-blue-500 text-white'
106 : 'border-gray-300'
107 )}
108 >
109 {value.includes(option.value) && <Check className="h-3 w-3" />}
110 </div>
111 {option.label}
112 </button>
113 ))}
114 </div>
115 </Popover.Content>
116 </Popover.Portal>
117 </Popover.Root>
118 )
119}Best Practices#
- Use semantic HTML - Dropdowns should use proper button and menu roles
- Support keyboard navigation - Arrow keys, Enter, and Escape should work
- Close on outside click - Dropdowns should close when clicking elsewhere
- Position intelligently - Flip to opposite side when near viewport edges
- Filter efficiently - Debounce search input for large option lists
Related Patterns#
- Modals - Modal and dialog patterns
- Forms - Form input patterns
- Navigation - Navigation menu patterns