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-react

Basic 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#

  1. Use semantic HTML - Dropdowns should use proper button and menu roles
  2. Support keyboard navigation - Arrow keys, Enter, and Escape should work
  3. Close on outside click - Dropdowns should close when clicking elsewhere
  4. Position intelligently - Flip to opposite side when near viewport edges
  5. Filter efficiently - Debounce search input for large option lists