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 DropdownMenuInstall 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"23import 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"78// Context9type DropdownMenuContextType = {10 open: boolean11 onOpenChange: (open: boolean) => void12 triggerRef: React.RefObject<HTMLElement | null>13}1415const DropdownMenuContext = createContext<DropdownMenuContextType | undefined>(undefined)1617function useDropdownMenu() {18 const context = useContext(DropdownMenuContext)19 if (!context) {20 throw new Error("DropdownMenu components must be used within a DropdownMenu")21 }22 return context23}2425// Root26type DropdownMenuProps = {27 children: ReactNode28 open?: boolean29 defaultOpen?: boolean30 onOpenChange?: (open: boolean) => void31}3233export 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)4142 const isControlled = controlledOpen !== undefined43 const open = isControlled ? controlledOpen : internalOpen4445 const handleOpenChange = useCallback((newOpen: boolean) => {46 if (!isControlled) {47 setInternalOpen(newOpen)48 }49 onOpenChange?.(newOpen)50 }, [isControlled, onOpenChange])5152 return (53 <DropdownMenuContext.Provider value={{ open, onOpenChange: handleOpenChange, triggerRef }}>54 <div className="relative inline-block">55 {children}56 </div>57 </DropdownMenuContext.Provider>58 )59}6061// Trigger62type DropdownMenuTriggerProps = {63 children: ReactNode64 className?: string65 asChild?: boolean66} & ButtonHTMLAttributes<HTMLButtonElement>6768export function DropdownMenuTrigger({ children, className, asChild, ...props }: DropdownMenuTriggerProps) {69 const { open, onOpenChange, triggerRef } = useDropdownMenu()7071 const handleClick = () => {72 onOpenChange(!open)73 }7475 if (asChild) {76 return (77 <span78 ref={triggerRef as React.RefObject<HTMLSpanElement>}79 onClick={handleClick}80 className={className}81 >82 {children}83 </span>84 )85 }8687 return (88 <button89 ref={triggerRef as React.RefObject<HTMLButtonElement>}90 type="button"91 className={className}92 onClick={handleClick}93 {...props}94 >95 {children}96 </button>97 )98}99100// Content101type DropdownMenuContentProps = {102 children: ReactNode103 className?: string104 align?: "start" | "center" | "end"105 side?: "bottom" | "top" | "left" | "right"106 sideOffset?: number107} & HTMLAttributes<HTMLDivElement>108109export function DropdownMenuContent({110 children,111 className,112 align = "start",113 side = "bottom",114 sideOffset = 4,115 ...props116}: DropdownMenuContentProps) {117 const { open, onOpenChange } = useDropdownMenu()118 const contentRef = useRef<HTMLDivElement>(null)119120 // Close on click outside121 useEffect(() => {122 if (!open) return123124 const handleClickOutside = (e: MouseEvent) => {125 const content = contentRef.current126 if (!content) return127 if (!content.contains(e.target as Node)) {128 onOpenChange(false)129 }130 }131132 // Delay to avoid catching the trigger click133 const timer = setTimeout(() => {134 document.addEventListener("mousedown", handleClickOutside)135 }, 0)136137 return () => {138 clearTimeout(timer)139 document.removeEventListener("mousedown", handleClickOutside)140 }141 }, [open, onOpenChange])142143 // Close on escape144 useEffect(() => {145 if (!open) return146147 const handleEscape = (e: KeyboardEvent) => {148 if (e.key === "Escape") {149 onOpenChange(false)150 }151 }152153 document.addEventListener("keydown", handleEscape)154 return () => document.removeEventListener("keydown", handleEscape)155 }, [open, onOpenChange])156157 if (!open) return null158159 const alignClasses = {160 start: "left-0",161 center: "left-1/2 -translate-x-1/2",162 end: "right-0",163 }164165 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 }171172 return (173 <div174 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 className186 )}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}199200// Item201type DropdownMenuItemProps = {202 children: ReactNode203 className?: string204 disabled?: boolean205 onSelect?: () => void206} & HTMLAttributes<HTMLDivElement>207208export function DropdownMenuItem({209 children,210 className,211 disabled = false,212 onSelect,213 ...props214}: DropdownMenuItemProps) {215 const { onOpenChange } = useDropdownMenu()216217 const handleClick = () => {218 if (disabled) return219 onSelect?.()220 onOpenChange(false)221 }222223 return (224 <div225 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 className233 )}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}247248// Label249type DropdownMenuLabelProps = {250 children: ReactNode251 className?: string252 inset?: boolean253} & HTMLAttributes<HTMLDivElement>254255export function DropdownMenuLabel({ children, className, inset, ...props }: DropdownMenuLabelProps) {256 return (257 <div258 className={cn(259 "px-2 py-1.5 text-sm font-semibold whitespace-nowrap",260 inset && "pl-8",261 className262 )}263 {...props}264 >265 {children}266 </div>267 )268}269270// Separator271type DropdownMenuSeparatorProps = {272 className?: string273} & HTMLAttributes<HTMLDivElement>274275export function DropdownMenuSeparator({ className, ...props }: DropdownMenuSeparatorProps) {276 return (277 <div278 role="separator"279 className={cn("-mx-1 my-1 h-px bg-border", className)}280 {...props}281 />282 )283}284285// Shortcut286type DropdownMenuShortcutProps = {287 children: ReactNode288 className?: string289} & HTMLAttributes<HTMLSpanElement>290291export function DropdownMenuShortcut({ children, className, ...props }: DropdownMenuShortcutProps) {292 return (293 <span294 className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}295 {...props}296 >297 {children}298 </span>299 )300}301302// Sub (submenu context)303type DropdownMenuSubContextType = {304 open: boolean305 onOpenChange: (open: boolean) => void306 timeoutRef: React.RefObject<NodeJS.Timeout | null>307}308309const DropdownMenuSubContext = createContext<DropdownMenuSubContextType | undefined>(undefined)310311function useDropdownMenuSub() {312 const context = useContext(DropdownMenuSubContext)313 if (!context) {314 throw new Error("DropdownMenuSub components must be used within a DropdownMenuSub")315 }316 return context317}318319type DropdownMenuSubProps = {320 children: ReactNode321}322323export function DropdownMenuSub({ children }: DropdownMenuSubProps) {324 const [open, setOpen] = useState(false)325 const timeoutRef = useRef<NodeJS.Timeout | null>(null)326327 useEffect(() => {328 return () => {329 if (timeoutRef.current) {330 clearTimeout(timeoutRef.current)331 }332 }333 }, [])334335 return (336 <DropdownMenuSubContext.Provider value={{ open, onOpenChange: setOpen, timeoutRef }}>337 <div className="relative">338 {children}339 </div>340 </DropdownMenuSubContext.Provider>341 )342}343344// SubTrigger345type DropdownMenuSubTriggerProps = {346 children: ReactNode347 className?: string348} & HTMLAttributes<HTMLDivElement>349350export function DropdownMenuSubTrigger({ children, className, ...props }: DropdownMenuSubTriggerProps) {351 const { open, onOpenChange, timeoutRef } = useDropdownMenuSub()352353 const handleMouseEnter = () => {354 if (timeoutRef.current) {355 clearTimeout(timeoutRef.current)356 timeoutRef.current = null357 }358 onOpenChange(true)359 }360361 const handleMouseLeave = () => {362 timeoutRef.current = setTimeout(() => {363 onOpenChange(false)364 timeoutRef.current = null365 }, 150)366 }367368 return (369 <div370 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 className378 )}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}398399// SubContent400type DropdownMenuSubContentProps = {401 children: ReactNode402 className?: string403} & HTMLAttributes<HTMLDivElement>404405export function DropdownMenuSubContent({ children, className, ...props }: DropdownMenuSubContentProps) {406 const { open, onOpenChange, timeoutRef } = useDropdownMenuSub()407408 const handleMouseEnter = () => {409 if (timeoutRef.current) {410 clearTimeout(timeoutRef.current)411 timeoutRef.current = null412 }413 }414415 const handleMouseLeave = () => {416 timeoutRef.current = setTimeout(() => {417 onOpenChange(false)418 timeoutRef.current = null419 }, 150)420 }421422 if (!open) return null423424 return (425 <div426 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 className432 )}433 onMouseEnter={handleMouseEnter}434 onMouseLeave={handleMouseLeave}435 {...props}436 >437 {children}438 </div>439 )440}441442// Group443type DropdownMenuGroupProps = {444 children: ReactNode445 className?: string446} & HTMLAttributes<HTMLDivElement>447448export function DropdownMenuGroup({ children, className, ...props }: DropdownMenuGroupProps) {449 return (450 <div role="group" className={className} {...props}>451 {children}452 </div>453 )454}455Usages
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>