Docs/Components/Dropdown Menu

Dropdown Menu

A menu that appears on click, with support for submenus, labels, separators and keyboard shortcuts.

Installation

Install the component Dropdown Menu in your project using the CLI.

Dropdown Menu.tsx

pnpm dlx behsseui@latest add DropdownMenu

Install the component manually.

Create a ui folder at the root of the project, then a component folder inside it, and finally a Dropdown Menu.tsx file in that folder.

Copy and paste the following code into your project.

ui/components/DropdownMenu.tsx

1"use client"
2
3import type { ReactNode, HTMLAttributes, ButtonHTMLAttributes } from "react"
4import { createContext, useContext, useState, useEffect, useCallback, useRef } from "react"
5import { cn } from "@/lib/utils"
6import ChevronRight from "@/ui/icons/ChevronRight"
7
8// Context
9type DropdownMenuContextType = {
10 open: boolean
11 onOpenChange: (open: boolean) => void
12 triggerRef: React.RefObject<HTMLElement | null>
13}
14
15const DropdownMenuContext = createContext<DropdownMenuContextType | undefined>(undefined)
16
17function useDropdownMenu() {
18 const context = useContext(DropdownMenuContext)
19 if (!context) {
20 throw new Error("DropdownMenu components must be used within a DropdownMenu")
21 }
22 return context
23}
24
25// Root
26type DropdownMenuProps = {
27 children: ReactNode
28 open?: boolean
29 defaultOpen?: boolean
30 onOpenChange?: (open: boolean) => void
31}
32
33export function DropdownMenu({
34 children,
35 open: controlledOpen,
36 defaultOpen = false,
37 onOpenChange,
38}: DropdownMenuProps) {
39 const [internalOpen, setInternalOpen] = useState(defaultOpen)
40 const triggerRef = useRef<HTMLElement | null>(null)
41
42 const isControlled = controlledOpen !== undefined
43 const open = isControlled ? controlledOpen : internalOpen
44
45 const handleOpenChange = useCallback((newOpen: boolean) => {
46 if (!isControlled) {
47 setInternalOpen(newOpen)
48 }
49 onOpenChange?.(newOpen)
50 }, [isControlled, onOpenChange])
51
52 return (
53 <DropdownMenuContext.Provider value={{ open, onOpenChange: handleOpenChange, triggerRef }}>
54 <div className="relative inline-block">
55 {children}
56 </div>
57 </DropdownMenuContext.Provider>
58 )
59}
60
61// Trigger
62type DropdownMenuTriggerProps = {
63 children: ReactNode
64 className?: string
65 asChild?: boolean
66} & ButtonHTMLAttributes<HTMLButtonElement>
67
68export function DropdownMenuTrigger({ children, className, asChild, ...props }: DropdownMenuTriggerProps) {
69 const { open, onOpenChange, triggerRef } = useDropdownMenu()
70
71 const handleClick = () => {
72 onOpenChange(!open)
73 }
74
75 if (asChild) {
76 return (
77 <span
78 ref={triggerRef as React.RefObject<HTMLSpanElement>}
79 onClick={handleClick}
80 className={className}
81 >
82 {children}
83 </span>
84 )
85 }
86
87 return (
88 <button
89 ref={triggerRef as React.RefObject<HTMLButtonElement>}
90 type="button"
91 className={className}
92 onClick={handleClick}
93 {...props}
94 >
95 {children}
96 </button>
97 )
98}
99
100// Content
101type DropdownMenuContentProps = {
102 children: ReactNode
103 className?: string
104 align?: "start" | "center" | "end"
105 side?: "bottom" | "top" | "left" | "right"
106 sideOffset?: number
107} & HTMLAttributes<HTMLDivElement>
108
109export function DropdownMenuContent({
110 children,
111 className,
112 align = "start",
113 side = "bottom",
114 sideOffset = 4,
115 ...props
116}: DropdownMenuContentProps) {
117 const { open, onOpenChange } = useDropdownMenu()
118 const contentRef = useRef<HTMLDivElement>(null)
119
120 // Close on click outside
121 useEffect(() => {
122 if (!open) return
123
124 const handleClickOutside = (e: MouseEvent) => {
125 const content = contentRef.current
126 if (!content) return
127 if (!content.contains(e.target as Node)) {
128 onOpenChange(false)
129 }
130 }
131
132 // Delay to avoid catching the trigger click
133 const timer = setTimeout(() => {
134 document.addEventListener("mousedown", handleClickOutside)
135 }, 0)
136
137 return () => {
138 clearTimeout(timer)
139 document.removeEventListener("mousedown", handleClickOutside)
140 }
141 }, [open, onOpenChange])
142
143 // Close on escape
144 useEffect(() => {
145 if (!open) return
146
147 const handleEscape = (e: KeyboardEvent) => {
148 if (e.key === "Escape") {
149 onOpenChange(false)
150 }
151 }
152
153 document.addEventListener("keydown", handleEscape)
154 return () => document.removeEventListener("keydown", handleEscape)
155 }, [open, onOpenChange])
156
157 if (!open) return null
158
159 const alignClasses = {
160 start: "left-0",
161 center: "left-1/2 -translate-x-1/2",
162 end: "right-0",
163 }
164
165 const sideClasses = {
166 bottom: `top-full mt-[${sideOffset}px]`,
167 top: `bottom-full mb-[${sideOffset}px]`,
168 left: `right-full mr-[${sideOffset}px] top-0`,
169 right: `left-full ml-[${sideOffset}px] top-0`,
170 }
171
172 return (
173 <div
174 ref={contentRef}
175 role="menu"
176 className={cn(
177 "absolute z-50 min-w-32 w-max rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md",
178 "animate-in fade-in-0 zoom-in-95",
179 side === "bottom" && "slide-in-from-top-2",
180 side === "top" && "slide-in-from-bottom-2",
181 side === "left" && "slide-in-from-right-2",
182 side === "right" && "slide-in-from-left-2",
183 sideClasses[side],
184 (side === "bottom" || side === "top") && alignClasses[align],
185 className
186 )}
187 style={{
188 ...(side === "bottom" ? { marginTop: `${sideOffset}px` } : {}),
189 ...(side === "top" ? { marginBottom: `${sideOffset}px` } : {}),
190 ...(side === "left" ? { marginRight: `${sideOffset}px` } : {}),
191 ...(side === "right" ? { marginLeft: `${sideOffset}px` } : {}),
192 }}
193 {...props}
194 >
195 {children}
196 </div>
197 )
198}
199
200// Item
201type DropdownMenuItemProps = {
202 children: ReactNode
203 className?: string
204 disabled?: boolean
205 onSelect?: () => void
206} & HTMLAttributes<HTMLDivElement>
207
208export function DropdownMenuItem({
209 children,
210 className,
211 disabled = false,
212 onSelect,
213 ...props
214}: DropdownMenuItemProps) {
215 const { onOpenChange } = useDropdownMenu()
216
217 const handleClick = () => {
218 if (disabled) return
219 onSelect?.()
220 onOpenChange(false)
221 }
222
223 return (
224 <div
225 role="menuitem"
226 tabIndex={disabled ? -1 : 0}
227 className={cn(
228 "relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors whitespace-nowrap",
229 "hover:bg-accent hover:text-accent-foreground",
230 "focus:bg-accent focus:text-accent-foreground",
231 disabled && "pointer-events-none opacity-50",
232 className
233 )}
234 onClick={handleClick}
235 onKeyDown={(e) => {
236 if (e.key === "Enter" || e.key === " ") {
237 e.preventDefault()
238 handleClick()
239 }
240 }}
241 {...props}
242 >
243 {children}
244 </div>
245 )
246}
247
248// Label
249type DropdownMenuLabelProps = {
250 children: ReactNode
251 className?: string
252 inset?: boolean
253} & HTMLAttributes<HTMLDivElement>
254
255export function DropdownMenuLabel({ children, className, inset, ...props }: DropdownMenuLabelProps) {
256 return (
257 <div
258 className={cn(
259 "px-2 py-1.5 text-sm font-semibold whitespace-nowrap",
260 inset && "pl-8",
261 className
262 )}
263 {...props}
264 >
265 {children}
266 </div>
267 )
268}
269
270// Separator
271type DropdownMenuSeparatorProps = {
272 className?: string
273} & HTMLAttributes<HTMLDivElement>
274
275export function DropdownMenuSeparator({ className, ...props }: DropdownMenuSeparatorProps) {
276 return (
277 <div
278 role="separator"
279 className={cn("-mx-1 my-1 h-px bg-border", className)}
280 {...props}
281 />
282 )
283}
284
285// Shortcut
286type DropdownMenuShortcutProps = {
287 children: ReactNode
288 className?: string
289} & HTMLAttributes<HTMLSpanElement>
290
291export function DropdownMenuShortcut({ children, className, ...props }: DropdownMenuShortcutProps) {
292 return (
293 <span
294 className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
295 {...props}
296 >
297 {children}
298 </span>
299 )
300}
301
302// Sub (submenu context)
303type DropdownMenuSubContextType = {
304 open: boolean
305 onOpenChange: (open: boolean) => void
306 timeoutRef: React.RefObject<NodeJS.Timeout | null>
307}
308
309const DropdownMenuSubContext = createContext<DropdownMenuSubContextType | undefined>(undefined)
310
311function useDropdownMenuSub() {
312 const context = useContext(DropdownMenuSubContext)
313 if (!context) {
314 throw new Error("DropdownMenuSub components must be used within a DropdownMenuSub")
315 }
316 return context
317}
318
319type DropdownMenuSubProps = {
320 children: ReactNode
321}
322
323export function DropdownMenuSub({ children }: DropdownMenuSubProps) {
324 const [open, setOpen] = useState(false)
325 const timeoutRef = useRef<NodeJS.Timeout | null>(null)
326
327 useEffect(() => {
328 return () => {
329 if (timeoutRef.current) {
330 clearTimeout(timeoutRef.current)
331 }
332 }
333 }, [])
334
335 return (
336 <DropdownMenuSubContext.Provider value={{ open, onOpenChange: setOpen, timeoutRef }}>
337 <div className="relative">
338 {children}
339 </div>
340 </DropdownMenuSubContext.Provider>
341 )
342}
343
344// SubTrigger
345type DropdownMenuSubTriggerProps = {
346 children: ReactNode
347 className?: string
348} & HTMLAttributes<HTMLDivElement>
349
350export function DropdownMenuSubTrigger({ children, className, ...props }: DropdownMenuSubTriggerProps) {
351 const { open, onOpenChange, timeoutRef } = useDropdownMenuSub()
352
353 const handleMouseEnter = () => {
354 if (timeoutRef.current) {
355 clearTimeout(timeoutRef.current)
356 timeoutRef.current = null
357 }
358 onOpenChange(true)
359 }
360
361 const handleMouseLeave = () => {
362 timeoutRef.current = setTimeout(() => {
363 onOpenChange(false)
364 timeoutRef.current = null
365 }, 150)
366 }
367
368 return (
369 <div
370 role="menuitem"
371 tabIndex={0}
372 className={cn(
373 "relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors whitespace-nowrap",
374 "hover:bg-accent hover:text-accent-foreground",
375 "focus:bg-accent focus:text-accent-foreground",
376 open && "bg-accent text-accent-foreground",
377 className
378 )}
379 onMouseEnter={handleMouseEnter}
380 onMouseLeave={handleMouseLeave}
381 onKeyDown={(e) => {
382 if (e.key === "Enter" || e.key === " " || e.key === "ArrowRight") {
383 e.preventDefault()
384 onOpenChange(true)
385 }
386 if (e.key === "ArrowLeft" || e.key === "Escape") {
387 e.preventDefault()
388 onOpenChange(false)
389 }
390 }}
391 {...props}
392 >
393 {children}
394 <ChevronRight className="ml-auto h-4 w-4" />
395 </div>
396 )
397}
398
399// SubContent
400type DropdownMenuSubContentProps = {
401 children: ReactNode
402 className?: string
403} & HTMLAttributes<HTMLDivElement>
404
405export function DropdownMenuSubContent({ children, className, ...props }: DropdownMenuSubContentProps) {
406 const { open, onOpenChange, timeoutRef } = useDropdownMenuSub()
407
408 const handleMouseEnter = () => {
409 if (timeoutRef.current) {
410 clearTimeout(timeoutRef.current)
411 timeoutRef.current = null
412 }
413 }
414
415 const handleMouseLeave = () => {
416 timeoutRef.current = setTimeout(() => {
417 onOpenChange(false)
418 timeoutRef.current = null
419 }, 150)
420 }
421
422 if (!open) return null
423
424 return (
425 <div
426 role="menu"
427 className={cn(
428 "absolute left-full top-0 z-50 min-w-32 w-max rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg",
429 "ml-1",
430 "animate-in fade-in-0 zoom-in-95 slide-in-from-left-2",
431 className
432 )}
433 onMouseEnter={handleMouseEnter}
434 onMouseLeave={handleMouseLeave}
435 {...props}
436 >
437 {children}
438 </div>
439 )
440}
441
442// Group
443type DropdownMenuGroupProps = {
444 children: ReactNode
445 className?: string
446} & HTMLAttributes<HTMLDivElement>
447
448export function DropdownMenuGroup({ children, className, ...props }: DropdownMenuGroupProps) {
449 return (
450 <div role="group" className={className} {...props}>
451 {children}
452 </div>
453 )
454}
455

Usages

Different variants and use cases for the Dropdown Menu component.

Default

A basic dropdown menu with items.

Default.tsx

<DropdownMenu>
  <DropdownMenuTrigger asChild>
    <Button variant="outline">Open Menu</Button>
  </DropdownMenuTrigger>
  <DropdownMenuContent>
    <DropdownMenuLabel>My Account</DropdownMenuLabel>
    <DropdownMenuSeparator />
    <DropdownMenuItem>Profile</DropdownMenuItem>
    <DropdownMenuItem>Settings</DropdownMenuItem>
    <DropdownMenuItem>Billing</DropdownMenuItem>
    <DropdownMenuSeparator />
    <DropdownMenuItem>Log out</DropdownMenuItem>
  </DropdownMenuContent>
</DropdownMenu>

With Shortcuts

Menu items with keyboard shortcut hints.

With Shortcuts.tsx

<DropdownMenu>
  <DropdownMenuTrigger asChild>
    <Button variant="outline">Actions</Button>
  </DropdownMenuTrigger>
  <DropdownMenuContent>
    <DropdownMenuItem>
      New Tab
      <DropdownMenuShortcut>T</DropdownMenuShortcut>
    </DropdownMenuItem>
    <DropdownMenuItem>
      New Window
      <DropdownMenuShortcut>N</DropdownMenuShortcut>
    </DropdownMenuItem>
    <DropdownMenuSeparator />
    <DropdownMenuItem disabled>
      New Private Window
      <DropdownMenuShortcut>N</DropdownMenuShortcut>
    </DropdownMenuItem>
  </DropdownMenuContent>
</DropdownMenu>

With Groups

Organize items into labeled groups with separators.

With Groups.tsx

<DropdownMenu>
  <DropdownMenuTrigger asChild>
    <Button>Settings</Button>
  </DropdownMenuTrigger>
  <DropdownMenuContent>
    <DropdownMenuLabel>Appearance</DropdownMenuLabel>
    <DropdownMenuGroup>
      <DropdownMenuItem>Theme</DropdownMenuItem>
      <DropdownMenuItem>Font Size</DropdownMenuItem>
    </DropdownMenuGroup>
    <DropdownMenuSeparator />
    <DropdownMenuLabel>Account</DropdownMenuLabel>
    <DropdownMenuGroup>
      <DropdownMenuItem>Profile</DropdownMenuItem>
      <DropdownMenuItem>Security</DropdownMenuItem>
    </DropdownMenuGroup>
  </DropdownMenuContent>
</DropdownMenu>