Dropdown Menu

Displays a menu to the user with a list of actions or functions triggered by a button.

1'use client';
2
3import { Bell, Keyboard, LogOut, Settings, Shield, User } from 'lucide-react';
4
5import { Button } from '@/components/ui/button';
6import { DropdownMenu } from '@/components/ui/dropdown-menu';
7
8export function Default() {
9 return (
10 <DropdownMenu>
11 <DropdownMenu.Trigger asChild>
12 <Button variant="outline">Account</Button>
13 </DropdownMenu.Trigger>
14 <DropdownMenu.Content className="w-56" align="start">
15 <DropdownMenu.Label>Account</DropdownMenu.Label>
16 <DropdownMenu.Item>
17 <User className="mr-2 size-4" />
18 Profile
19 </DropdownMenu.Item>
20 <DropdownMenu.Item>
21 <Bell className="mr-2 size-4" />
22 Notifications
23 </DropdownMenu.Item>
24 <DropdownMenu.Item>
25 <Settings className="mr-2 size-4" />
26 Settings
27 </DropdownMenu.Item>
28 <DropdownMenu.Separator />
29 <DropdownMenu.Label>Security</DropdownMenu.Label>
30 <DropdownMenu.Item>
31 <Shield className="mr-2 size-4" />
32 Privacy & Security
33 </DropdownMenu.Item>
34 <DropdownMenu.Item>
35 <Keyboard className="mr-2 size-4" />
36 Keyboard shortcuts
37 </DropdownMenu.Item>
38 <DropdownMenu.Separator />
39 <DropdownMenu.Item className="text-destructive focus:text-destructive">
40 <LogOut className="mr-2 size-4" />
41 Sign out
42 </DropdownMenu.Item>
43 </DropdownMenu.Content>
44 </DropdownMenu>
45 );
46}

Installation

Copy and paste the following code in your project.
'use client';
import { ArrowDown01Icon } from '@hugeicons/core-free-icons';
import { HugeiconsIcon } from '@hugeicons/react';
import { AnimatePresence, motion, useReducedMotion } from 'motion/react';
import * as React from 'react';
import { cn } from '../lib/cn';
// --- Interfaces ---
interface DropdownContextValue {
isOpen: boolean;
toggleMenu: () => void;
closeMenu: () => void;
openMenu: () => void;
triggerRef: React.RefObject<HTMLButtonElement | null>;
triggerId: string;
contentId: string;
}
interface DropdownMenuProps {
children: React.ReactNode;
className?: string;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}
interface DropdownMenuTriggerProps {
children: React.ReactNode;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
className?: string;
asChild?: boolean;
}
interface DropdownMenuContentProps {
children: React.ReactNode;
className?: string;
align?: 'start' | 'center' | 'end';
sideOffset?: number;
}
interface DropdownMenuItemProps {
children: React.ReactNode;
onClick?: (event: React.MouseEvent<HTMLDivElement>) => void;
className?: string;
disabled?: boolean;
onSelect?: () => void;
variant?: 'default' | 'destructive';
asChild?: boolean;
}
// --- Animation constants (module level) ---
const DROPDOWN_ICON_VARIANTS = {
open: { rotate: 180 },
closed: { rotate: 0 },
} as const;
const DROPDOWN_ICON_TRANSITION = { type: 'spring', stiffness: 300, damping: 20 } as const;
const DROPDOWN_ICON_STYLE = { willChange: 'transform' } as const;
const DROPDOWN_CONTENT_STYLE = { willChange: 'opacity, transform, filter' } as const;
// --- Context ---
const DropdownContext = React.createContext<DropdownContextValue | null>(null);
const useDropdownContext = (): DropdownContextValue => {
const context = React.use(DropdownContext);
if (!context) throw new Error('Dropdown components must be used within DropdownMenu');
return context;
};
// --- Helpers ---
function useClickOutside(
ref: React.RefObject<HTMLElement | null>,
triggerRef: React.RefObject<HTMLElement | null>,
handler: () => void,
enabled: boolean,
) {
React.useEffect(() => {
if (!enabled) return;
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
if (
ref.current?.contains(event.target as Node) ||
triggerRef.current?.contains(event.target as Node)
) {
return;
}
handler();
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('touchstart', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchstart', handleClickOutside);
};
}, [ref, triggerRef, handler, enabled]);
}
// --- Components ---
const DropdownMenuRoot = ({
children,
className,
defaultOpen = false,
onOpenChange,
}: DropdownMenuProps): React.JSX.Element => {
const [isOpen, setIsOpen] = React.useState(defaultOpen);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const openMenu = React.useCallback(() => {
setIsOpen(true);
onOpenChange?.(true);
}, [onOpenChange]);
const closeMenu = React.useCallback(() => {
setIsOpen(false);
onOpenChange?.(false);
}, [onOpenChange]);
const toggleMenu = React.useCallback(() => {
setIsOpen((prev) => {
const next = !prev;
onOpenChange?.(next);
return next;
});
}, [onOpenChange]);
const id = React.useId();
const triggerId = `dropdown-trigger-${id}`;
const contentId = `dropdown-content-${id}`;
const contextValue = React.useMemo(
() => ({
isOpen,
openMenu,
closeMenu,
toggleMenu,
triggerRef,
triggerId,
contentId,
}),
[isOpen, openMenu, closeMenu, toggleMenu, triggerId, contentId],
);
return (
<DropdownContext value={contextValue}>
<div className={cn('relative inline-block text-left', className)}>{children}</div>
</DropdownContext>
);
};
const DropdownMenuTrigger = ({
children,
onClick,
className,
asChild = false,
}: DropdownMenuTriggerProps): React.JSX.Element => {
const { isOpen, toggleMenu, triggerRef, triggerId, contentId } = useDropdownContext();
const shouldReduceMotion = useReducedMotion();
const handleClick = React.useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
toggleMenu();
onClick?.(e);
},
[toggleMenu, onClick],
);
if (asChild && React.isValidElement(children)) {
return React.cloneElement(children, {
ref: triggerRef,
onClick: handleClick,
'aria-expanded': isOpen,
'aria-haspopup': 'menu' as const,
'aria-controls': contentId,
id: triggerId,
} as React.HTMLAttributes<HTMLElement>);
}
return (
<motion.button
ref={triggerRef}
type="button"
onClick={handleClick}
whileTap={!shouldReduceMotion ? { scale: 0.98 } : undefined}
className={cn(
'inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium',
'text-foreground border-border border',
'hover:bg-muted transition-colors',
'focus-visible:ring-ring focus-visible:ring-1 focus-visible:outline-none',
className,
)}
aria-expanded={isOpen}
aria-haspopup="menu"
aria-controls={contentId}
id={triggerId}
>
{children}
<motion.span
variants={DROPDOWN_ICON_VARIANTS}
animate={isOpen ? 'open' : 'closed'}
transition={DROPDOWN_ICON_TRANSITION}
style={DROPDOWN_ICON_STYLE}
>
<HugeiconsIcon icon={ArrowDown01Icon} className="h-4 w-4 opacity-50" size={16} />
</motion.span>
</motion.button>
);
};
const DropdownMenuContent = ({
children,
className,
align = 'start',
sideOffset = 6,
}: DropdownMenuContentProps): React.JSX.Element | null => {
const { isOpen, closeMenu, contentId, triggerId, triggerRef } = useDropdownContext();
const contentRef = React.useRef<HTMLDivElement>(null);
const [position, setPosition] = React.useState<'bottom' | 'top'>('bottom');
useClickOutside(contentRef, triggerRef, closeMenu, isOpen);
React.useLayoutEffect(() => {
if (!isOpen || !triggerRef.current) return;
const updatePosition = () => {
const triggerRect = triggerRef.current!.getBoundingClientRect();
const contentHeight = contentRef.current?.offsetHeight || 200;
const windowHeight = window.innerHeight;
const spaceBelow = windowHeight - triggerRect.bottom;
const newPosition = spaceBelow < contentHeight + 20 ? 'top' : 'bottom';
setPosition(newPosition);
};
updatePosition();
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
return () => {
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}, [isOpen, triggerRef]);
React.useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeMenu();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, closeMenu]);
const alignClasses = {
start: 'left-0 origin-top-left',
center: 'left-1/2 -translate-x-1/2 origin-top',
end: 'right-0 origin-top-right',
};
const transformOriginClass =
position === 'bottom'
? align === 'start'
? 'origin-top-left'
: align === 'end'
? 'origin-top-right'
: 'origin-top'
: align === 'start'
? 'origin-bottom-left'
: align === 'end'
? 'origin-bottom-right'
: 'origin-bottom';
const verticalStyle =
position === 'bottom'
? { top: `calc(100% + ${sideOffset}px)` }
: { bottom: `calc(100% + ${sideOffset}px)` };
return (
<AnimatePresence>
{isOpen && (
<motion.div
ref={contentRef}
id={contentId}
role="menu"
aria-labelledby={triggerId}
initial={{
opacity: 0,
scale: 0.95,
y: position === 'bottom' ? -8 : 8,
filter: 'blur(4px)',
}}
animate={{
opacity: 1,
scale: 1,
y: 0,
filter: 'blur(0px)',
transition: {
type: 'spring',
duration: 0.3,
bounce: 0,
opacity: { duration: 0.2 },
},
}}
exit={{
opacity: 0,
scale: 0.98,
y: position === 'bottom' ? -4 : 4,
filter: 'blur(2px)',
transition: { duration: 0.15 },
}}
style={{ ...verticalStyle, ...DROPDOWN_CONTENT_STYLE }}
className={cn(
'border-border absolute z-50 min-w-48 overflow-hidden rounded-md border',
'bg-background backdrop-blur-lg',
alignClasses[align].split(' ')[0],
transformOriginClass,
className,
)}
>
<div className="flex flex-col gap-0.5 p-1.5">{children}</div>
</motion.div>
)}
</AnimatePresence>
);
};
const DropdownMenuItem = ({
children,
onClick,
className,
disabled = false,
variant = 'default',
onSelect,
asChild = false,
}: DropdownMenuItemProps): React.JSX.Element => {
const { closeMenu } = useDropdownContext();
const handleClick = React.useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (disabled) return;
e.stopPropagation();
onClick?.(e as any);
onSelect?.();
closeMenu();
},
[disabled, onClick, onSelect, closeMenu],
);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (disabled) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onClick?.(e as any);
onSelect?.();
closeMenu();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
const next = e.currentTarget.nextElementSibling as HTMLElement;
if (next) next.focus();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const prev = e.currentTarget.previousElementSibling as HTMLElement;
if (prev) prev.focus();
}
},
[disabled, onClick, onSelect, closeMenu],
);
const style = {
'--accent': variant === 'destructive' ? 'rgba(239, 68, 68, 0.1)' : 'rgba(0,0,0, 0.04)',
} as React.CSSProperties;
const content = (
<motion.div
role="menuitem"
tabIndex={disabled ? -1 : 0}
onClick={handleClick}
onKeyDown={handleKeyDown}
whileHover={!disabled ? { backgroundColor: 'var(--accent)', scale: 1 } : {}}
whileTap={!disabled ? { scale: 0.98 } : {}}
className={cn(
'relative flex cursor-pointer items-center rounded-md px-3 py-2 text-sm outline-none select-none',
'transition-colors duration-200',
disabled && 'pointer-events-none opacity-50',
variant === 'destructive' && 'text-destructive focus:text-destructive',
className,
)}
style={style}
>
{children}
</motion.div>
);
if (asChild && React.isValidElement(children)) {
return React.cloneElement(children, {
onClick: handleClick,
onKeyDown: handleKeyDown,
role: 'menuitem',
tabIndex: disabled ? -1 : 0,
className: cn(
'relative flex cursor-pointer items-center rounded-md px-3 py-2 text-sm outline-none select-none',
'transition-colors duration-200',
disabled && 'pointer-events-none opacity-50',
variant === 'destructive' && 'text-destructive focus:text-destructive',
className,
(children.props as any).className,
),
style: { ...style, ...(children.props as any).style },
} as any);
}
return content;
};
const DropdownLabel = ({ children }: { children: React.ReactNode }) => {
return (
<div className="text-muted-foreground px-3 py-2 text-xs font-semibold tracking-wider uppercase">
{children}
</div>
);
};
const DropdownSeparator = () => {
return <div className="bg-border/50 my-1 h-px" />;
};
const DropdownMenu = Object.assign(DropdownMenuRoot, {
Trigger: DropdownMenuTrigger,
Content: DropdownMenuContent,
Item: DropdownMenuItem,
Label: DropdownLabel,
Separator: DropdownSeparator,
});
export { DropdownMenu };
export type {
DropdownMenuContentProps,
DropdownMenuItemProps,
DropdownMenuProps,
DropdownMenuTriggerProps,
};
Make sure to update the import paths according to your project structure.

Anatomy

import { DropdownMenu } from '@/components/ui/dropdown-menu';
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<Button variant="outline">Open Menu</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Label>My Account</DropdownMenu.Label>
<DropdownMenu.Item>Profile</DropdownMenu.Item>
<DropdownMenu.Item>Settings</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item variant="destructive">Logout</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>

API Reference

The root component that manages menu state.
PropTypeDefaultDescription
defaultOpenbooleanfalseInitial open state
onOpenChange(open: boolean) => void-Callback when open state changes
classNamestring-Additional CSS classes
Button that toggles the dropdown menu.
PropTypeDefaultDescription
asChildbooleanfalseRender as child element
classNamestring-Additional CSS classes
Container for menu items with positioning and animations.
PropTypeDefaultDescription
align'start' | 'center' | 'end''start'Horizontal alignment
sideOffsetnumber6Distance from trigger
classNamestring-Additional CSS classes
Individual menu item that can be clicked.
PropTypeDefaultDescription
disabledbooleanfalseDisable the item
variant'default' | 'destructive''default'Visual style
onSelect() => void-Callback when selected
classNamestring-Additional CSS classes
Label for grouping menu items.
PropTypeDefaultDescription
childrenReactNode-Label text
Visual separator between menu items.
No props - renders a horizontal line.