Dropdown Menu
Displays a menu to the user or functions.
The Dropdown Menu component displays a menu when the user clicks on a trigger element. It supports keyboard shortcuts, separators, icons, and disabled items. Built from scratch using React and native HTML elements. No dependencies on any UI library.
Code
TypeScript: Copy this code into components/ui/dropdown-menu.tsx:
tsx
"use client"
import * as React from "react"
import * as ReactDOM from "react-dom"
import { cn } from "@/lib/utils"
interface DropdownMenuContextValue {
open: boolean
setOpen: (open: boolean) => void
triggerRef: React.RefObject<HTMLElement>
}
const DropdownMenuContext = React.createContext<DropdownMenuContextValue | undefined>(undefined)
const useDropdownMenu = () => {
const context = React.useContext(DropdownMenuContext)
if (!context) {
throw new Error("DropdownMenu components must be used within DropdownMenu")
}
return context
}
interface DropdownMenuProps {
open?: boolean
onOpenChange?: (open: boolean) => void
children: React.ReactNode
}
const DropdownMenu = ({ open: controlledOpen, onOpenChange, children }: DropdownMenuProps) => {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
const triggerRef = React.useRef<HTMLElement | null>(null)
const isControlled = controlledOpen !== undefined
const open = isControlled ? controlledOpen : uncontrolledOpen
const handleOpenChange = React.useCallback((newOpen: boolean) => {
if (!isControlled) {
setUncontrolledOpen(newOpen)
}
onOpenChange?.(newOpen)
}, [isControlled, onOpenChange])
React.useEffect(() => {
if (!open) return
const handleClick = (e: MouseEvent) => {
const target = e.target as Node
const portalContent = document.querySelector('[data-dropdown-menu-content]')
if (
portalContent &&
!portalContent.contains(target) &&
triggerRef.current &&
!triggerRef.current.contains(target)
) {
handleOpenChange(false)
}
}
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
handleOpenChange(false)
}
}
document.addEventListener("mousedown", handleClick, true)
document.addEventListener("keydown", handleEscape)
return () => {
document.removeEventListener("mousedown", handleClick, true)
document.removeEventListener("keydown", handleEscape)
}
}, [open, handleOpenChange])
return (
<DropdownMenuContext.Provider value={{ open, setOpen: handleOpenChange, triggerRef }}>
{children}
</DropdownMenuContext.Provider>
)
}
interface DropdownMenuTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean
}
const DropdownMenuTrigger = React.forwardRef<HTMLButtonElement, DropdownMenuTriggerProps>(
({ className, children, onClick, asChild, ...props }, ref) => {
const { open, setOpen, triggerRef: contextTriggerRef } = useDropdownMenu()
const localTriggerRef = React.useRef<HTMLButtonElement>(null)
React.useImperativeHandle(ref, () => localTriggerRef.current as HTMLButtonElement)
React.useEffect(() => {
if (localTriggerRef.current) {
contextTriggerRef.current = localTriggerRef.current
}
}, [contextTriggerRef])
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
setOpen(!open)
onClick?.(e)
}
if (asChild && React.isValidElement(children)) {
return React.cloneElement(children, {
onClick: handleClick,
ref: localTriggerRef,
"data-dropdown-menu-trigger": true,
...props,
} as any)
}
return (
<button
ref={localTriggerRef}
data-dropdown-menu-trigger
onClick={handleClick}
className={className}
{...props}
>
{children}
</button>
)
}
)
DropdownMenuTrigger.displayName = "DropdownMenuTrigger"
interface DropdownMenuContentProps extends React.HTMLAttributes<HTMLDivElement> {
align?: "start" | "end" | "center"
}
const DropdownMenuContent = React.forwardRef<HTMLDivElement, DropdownMenuContentProps>(
({ className, align = "start", children, ...props }, ref) => {
const { open, setOpen, triggerRef } = useDropdownMenu()
const contentRef = React.useRef<HTMLDivElement>(null)
React.useImperativeHandle(ref, () => contentRef.current as HTMLDivElement)
React.useEffect(() => {
if (!open || !contentRef.current || !triggerRef.current) return
const content = contentRef.current
const trigger = triggerRef.current
const triggerRect = trigger.getBoundingClientRect()
const contentRect = content.getBoundingClientRect()
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
let top = triggerRect.bottom + 4
let left = triggerRect.left
if (align === "end") {
left = triggerRect.right - contentRect.width
} else if (align === "center") {
left = triggerRect.left + (triggerRect.width - contentRect.width) / 2
}
if (left + contentRect.width > viewportWidth) {
left = viewportWidth - contentRect.width - 8
}
if (left < 8) left = 8
if (top + contentRect.height > viewportHeight) {
top = triggerRect.top - contentRect.height - 4
}
if (top < 8) top = 8
content.style.position = "fixed"
content.style.top = `${top}px`
content.style.left = `${left}px`
}, [open, align, triggerRef])
if (!open) return null
const content = (
<div
ref={contentRef}
data-dropdown-menu-content
className={cn(
"fixed z-50 min-w-[8rem] rounded-md border-2 border-foreground bg-background neobrutalism-shadow p-1",
className
)}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
{...props}
>
{children}
</div>
)
return ReactDOM.createPortal(content, document.body)
}
)
DropdownMenuContent.displayName = "DropdownMenuContent"
interface DropdownMenuItemProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
onSelect?: (e: React.MouseEvent<HTMLButtonElement>) => void
inset?: boolean
}
const DropdownMenuItem = React.forwardRef<HTMLButtonElement, DropdownMenuItemProps>(
({ className, onClick, onSelect, inset, ...props }, ref) => {
const { setOpen } = useDropdownMenu()
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(e)
onSelect?.(e)
if (!e.defaultPrevented && !onSelect) {
setOpen(false)
}
}
return (
<button
ref={ref}
onClick={handleClick}
onMouseDown={(e) => e.preventDefault()}
className={cn(
"relative flex w-full cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm font-bold outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
)
}
)
DropdownMenuItem.displayName = "DropdownMenuItem"
interface DropdownMenuLabelProps extends React.HTMLAttributes<HTMLDivElement> {}
const DropdownMenuLabel = React.forwardRef<HTMLDivElement, DropdownMenuLabelProps>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("px-2 py-1.5 text-sm font-bold text-muted-foreground", className)}
{...props}
/>
)
)
DropdownMenuLabel.displayName = "DropdownMenuLabel"
const DropdownMenuSeparator = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("h-px bg-foreground my-1 mx-1", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = "DropdownMenuSeparator"
interface DropdownMenuShortcutProps extends React.HTMLAttributes<HTMLSpanElement> {}
const DropdownMenuShortcut = React.forwardRef<HTMLSpanElement, DropdownMenuShortcutProps>(
({ className, ...props }, ref) => (
<span
ref={ref}
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props}
/>
)
)
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
}JavaScript: Copy this code into components/ui/dropdown-menu.jsx:
jsx
"use client"
import * as React from "react"
import * as ReactDOM from "react-dom"
import { cn } from "@/lib/utils"
const DropdownMenuContext = React.createContext(undefined)
const useDropdownMenu = () => {
const context = React.useContext(DropdownMenuContext)
if (!context) {
throw new Error("DropdownMenu components must be used within DropdownMenu")
}
return context
}
const DropdownMenu = ({ open: controlledOpen, onOpenChange, children }) => {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
const triggerRef = React.useRef(null)
const isControlled = controlledOpen !== undefined
const open = isControlled ? controlledOpen : uncontrolledOpen
const handleOpenChange = React.useCallback((newOpen) => {
if (!isControlled) {
setUncontrolledOpen(newOpen)
}
onOpenChange?.(newOpen)
}, [isControlled, onOpenChange])
React.useEffect(() => {
if (!open) return
const handleClick = (e) => {
const target = e.target
const portalContent = document.querySelector('[data-dropdown-menu-content]')
if (
portalContent &&
!portalContent.contains(target) &&
triggerRef.current &&
!triggerRef.current.contains(target)
) {
handleOpenChange(false)
}
}
const handleEscape = (e) => {
if (e.key === "Escape") {
handleOpenChange(false)
}
}
document.addEventListener("mousedown", handleClick, true)
document.addEventListener("keydown", handleEscape)
return () => {
document.removeEventListener("mousedown", handleClick, true)
document.removeEventListener("keydown", handleEscape)
}
}, [open, handleOpenChange])
return (
<DropdownMenuContext.Provider value={{ open, setOpen: handleOpenChange, triggerRef }}>
{children}
</DropdownMenuContext.Provider>
)
}
const DropdownMenuTrigger = React.forwardRef(
({ className, children, onClick, asChild, ...props }, ref) => {
const { open, setOpen, triggerRef: contextTriggerRef } = useDropdownMenu()
const localTriggerRef = React.useRef(null)
React.useImperativeHandle(ref, () => localTriggerRef.current)
React.useEffect(() => {
if (localTriggerRef.current) {
contextTriggerRef.current = localTriggerRef.current
}
}, [contextTriggerRef])
const handleClick = (e) => {
setOpen(!open)
onClick?.(e)
}
if (asChild && React.isValidElement(children)) {
return React.cloneElement(children, {
onClick: handleClick,
ref: localTriggerRef,
"data-dropdown-menu-trigger": true,
...props,
})
}
return (
<button
ref={localTriggerRef}
data-dropdown-menu-trigger
onClick={handleClick}
className={className}
{...props}
>
{children}
</button>
)
}
)
DropdownMenuTrigger.displayName = "DropdownMenuTrigger"
const DropdownMenuContent = React.forwardRef(
({ className, align = "start", children, ...props }, ref) => {
const { open, setOpen, triggerRef } = useDropdownMenu()
const contentRef = React.useRef(null)
React.useImperativeHandle(ref, () => contentRef.current)
React.useEffect(() => {
if (!open || !contentRef.current || !triggerRef.current) return
const content = contentRef.current
const trigger = triggerRef.current
const triggerRect = trigger.getBoundingClientRect()
const contentRect = content.getBoundingClientRect()
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
let top = triggerRect.bottom + 4
let left = triggerRect.left
if (align === "end") {
left = triggerRect.right - contentRect.width
} else if (align === "center") {
left = triggerRect.left + (triggerRect.width - contentRect.width) / 2
}
if (left + contentRect.width > viewportWidth) {
left = viewportWidth - contentRect.width - 8
}
if (left < 8) left = 8
if (top + contentRect.height > viewportHeight) {
top = triggerRect.top - contentRect.height - 4
}
if (top < 8) top = 8
content.style.position = "fixed"
content.style.top = `${top}px`
content.style.left = `${left}px`
}, [open, align, triggerRef])
if (!open) return null
const content = (
<div
ref={contentRef}
data-dropdown-menu-content
className={cn(
"fixed z-50 min-w-[8rem] rounded-md border-2 border-foreground bg-background neobrutalism-shadow p-1",
className
)}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
{...props}
>
{children}
</div>
)
return ReactDOM.createPortal(content, document.body)
}
)
DropdownMenuContent.displayName = "DropdownMenuContent"
const DropdownMenuItem = React.forwardRef(
({ className, onClick, onSelect, inset, ...props }, ref) => {
const { setOpen } = useDropdownMenu()
const handleClick = (e) => {
onClick?.(e)
onSelect?.(e)
if (!e.defaultPrevented && !onSelect) {
setOpen(false)
}
}
return (
<button
ref={ref}
onClick={handleClick}
onMouseDown={(e) => e.preventDefault()}
className={cn(
"relative flex w-full cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm font-bold outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
)
}
)
DropdownMenuItem.displayName = "DropdownMenuItem"
const DropdownMenuLabel = React.forwardRef(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("px-2 py-1.5 text-sm font-bold text-muted-foreground", className)}
{...props}
/>
)
)
DropdownMenuLabel.displayName = "DropdownMenuLabel"
const DropdownMenuSeparator = React.forwardRef(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("h-px bg-foreground my-1 mx-1", className)}
{...props}
/>
)
)
DropdownMenuSeparator.displayName = "DropdownMenuSeparator"
const DropdownMenuShortcut = React.forwardRef(
({ className, ...props }, ref) => (
<span
ref={ref}
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props}
/>
)
)
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
}Usage
TypeScript:
tsx
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
} from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
function MyComponent() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button>Open</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>
Profile
<DropdownMenuShortcut>โงโP</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
Billing
<DropdownMenuShortcut>โB</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
Settings
<DropdownMenuShortcut>โS</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>Log out</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}JavaScript:
jsx
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
} from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
function MyComponent() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button>Open</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>
Profile
<DropdownMenuShortcut>โงโP</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
Billing
<DropdownMenuShortcut>โB</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
Settings
<DropdownMenuShortcut>โS</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>Log out</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}Make sure you also have the lib/utils.ts file with the cn helper function, and the required UI components (button).