Dropdown Menu
Group secondary actions. Save screen space. Keep UI clean.
1'use client';23import { Avatar } from '@/components/ui/avatar';4import { Button } from '@/components/ui/button';5import { DropdownMenu } from '@/components/ui/dropdown-menu';6import { BellIcon } from '@/components/ui/bell';7import { CreditCardIcon } from '@/components/ui/credit-card';8import { KeyIcon } from '@/components/ui/key';9import { LogOutIcon } from '@/components/ui/log-out';10import { SettingsIcon } from '@/components/ui/settings';11import { UserIcon } from '@/components/ui/user';12import { UsersIcon } from '@/components/ui/users';1314const menuGroups = [15 {16 label: 'Account',17 items: [18 { icon: UserIcon, label: 'Profile', shortcut: 'โงโP' },19 { icon: BellIcon, label: 'Notifications', shortcut: null },20 { icon: SettingsIcon, label: 'Settings', shortcut: 'โ,' },21 ],22 },23 {24 label: 'Northwind',25 items: [26 { icon: UsersIcon, label: 'Team members', shortcut: null },27 { icon: CreditCardIcon, label: 'Usage and billing', shortcut: null },28 { icon: KeyIcon, label: 'API keys', shortcut: null },29 ],30 },31];3233export function Default() {34 return (35 <DropdownMenu>36 <DropdownMenu.Trigger asChild>37 <Button variant="outline">Daniela Rojas</Button>38 </DropdownMenu.Trigger>39 <DropdownMenu.Content className="w-64 max-w-[calc(100vw-2rem)]" align="start">40 <div className="flex items-center gap-3 px-3 py-2">41 <Avatar size="sm">42 <Avatar.Fallback>DR</Avatar.Fallback>43 </Avatar>44 <div className="min-w-0">45 <p className="truncate text-sm font-medium">Daniela Rojas</p>46 <p className="text-muted-foreground truncate text-xs">daniela@northwind.io</p>47 </div>48 </div>49 {menuGroups.map((group) => (50 <div key={group.label}>51 <DropdownMenu.Separator />52 <DropdownMenu.Label>{group.label}</DropdownMenu.Label>53 {group.items.map((item) => (54 <DropdownMenu.Item key={item.label} className="justify-between">55 <span className="flex items-center">56 <item.icon className="mr-2 size-4" />57 {item.label}58 </span>59 {item.shortcut && (60 <span className="text-muted-foreground text-xs">{item.shortcut}</span>61 )}62 </DropdownMenu.Item>63 ))}64 </div>65 ))}66 <DropdownMenu.Separator />67 <DropdownMenu.Item variant="destructive">68 <LogOutIcon className="size-4" />69 Sign out70 </DropdownMenu.Item>71 </DropdownMenu.Content>72 </DropdownMenu>73 );74}
Installation
Copy and paste the following code into your project.
1'use client';23import { AnimatePresence, motion, useReducedMotion } from 'motion/react';4import * as React from 'react';5import { cn } from '../lib/cn';6import { FloatingPortal, useFloating } from '../lib/floating';7import { floatingVariants, reveal, springs, tap } from '../lib/motion';8import { useLayer } from '../lib/overlay';9import { useControllableState } from '../lib/use-controllable-state';1011type IconProps = React.SVGProps<SVGSVGElement> & {12 size?: number | string;13};1415function ChevronDownIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {16 return (17 <svg18 xmlns="http://www.w3.org/2000/svg"19 width={size}20 height={size}21 viewBox="0 0 24 24"22 fill="none"23 stroke="currentColor"24 strokeWidth={strokeWidth}25 strokeLinecap="round"26 strokeLinejoin="round"27 aria-hidden="true"28 {...props}29 >30 <path d="m6 9 6 6 6-6" />31 </svg>32 );33}3435function CheckIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {36 return (37 <svg38 xmlns="http://www.w3.org/2000/svg"39 width={size}40 height={size}41 viewBox="0 0 24 24"42 fill="none"43 stroke="currentColor"44 strokeWidth={strokeWidth}45 strokeLinecap="round"46 strokeLinejoin="round"47 aria-hidden="true"48 {...props}49 >50 <path d="M20 6 9 17l-5-5" />51 </svg>52 );53}5455function DotIcon({ size = 24, ...props }: IconProps) {56 return (57 <svg58 xmlns="http://www.w3.org/2000/svg"59 width={size}60 height={size}61 viewBox="0 0 24 24"62 fill="currentColor"63 aria-hidden="true"64 {...props}65 >66 <circle cx="12" cy="12" r="4" />67 </svg>68 );69}7071type FocusEdge = 'first' | 'last';7273interface DropdownContextValue {74 isOpen: boolean;75 toggleMenu: () => void;76 closeMenu: (options?: { restoreFocus?: boolean }) => void;77 openMenu: (focus?: FocusEdge) => void;78 initialFocusRef: React.RefObject<FocusEdge>;79 triggerRef: React.RefObject<HTMLButtonElement | null>;80 contentRef: React.RefObject<HTMLDivElement | null>;81 triggerId: string;82 contentId: string;83}8485interface DropdownMenuProps {86 children: React.ReactNode;87 className?: string;88 defaultOpen?: boolean;89 open?: boolean;90 onOpenChange?: (open: boolean) => void;91}9293interface DropdownMenuTriggerProps {94 children: React.ReactNode;95 onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;96 className?: string;97 asChild?: boolean;98}99100interface DropdownMenuContentProps {101 children: React.ReactNode;102 className?: string;103 align?: 'start' | 'center' | 'end';104 sideOffset?: number;105}106107type DropdownMenuItemRole = 'menuitem' | 'menuitemcheckbox' | 'menuitemradio';108109interface DropdownMenuItemProps {110 children: React.ReactNode;111 onClick?: (event: React.MouseEvent<HTMLDivElement>) => void;112 className?: string;113 disabled?: boolean;114 onSelect?: () => void;115 variant?: 'default' | 'destructive';116 asChild?: boolean;117 role?: DropdownMenuItemRole;118 checked?: boolean;119 closeOnSelect?: boolean;120}121122interface DropdownMenuCheckableItemProps extends Omit<123 DropdownMenuItemProps,124 'role' | 'checked' | 'asChild'125> {126 checked?: boolean;127 onCheckedChange?: (checked: boolean) => void;128}129130const DROPDOWN_ICON_VARIANTS = {131 open: { rotate: 180 },132 closed: { rotate: 0 },133} as const;134135const DROPDOWN_ICON_STYLE = { willChange: 'transform' } as const;136const DROPDOWN_CONTENT_STYLE = { willChange: 'opacity, transform, filter' } as const;137138const TYPEAHEAD_RESET_MS = 500;139140const ORIGIN_CLASSES = {141 bottom: { start: 'origin-top-left', center: 'origin-top', end: 'origin-top-right' },142 top: { start: 'origin-bottom-left', center: 'origin-bottom', end: 'origin-bottom-right' },143} as const;144145const DropdownContext = React.createContext<DropdownContextValue | null>(null);146147const useDropdownContext = (): DropdownContextValue => {148 const context = React.use(DropdownContext);149 if (!context) throw new Error('Dropdown components must be used within DropdownMenu');150 return context;151};152153function useClickOutside(154 ref: React.RefObject<HTMLElement | null>,155 triggerRef: React.RefObject<HTMLElement | null>,156 handler: () => void,157 enabled: boolean,158 isInsideUpperLayer: (target: EventTarget | null) => boolean,159) {160 React.useEffect(() => {161 if (!enabled) return;162 const handlePointerDownOutside = (event: PointerEvent) => {163 if (164 ref.current?.contains(event.target as Node) ||165 triggerRef.current?.contains(event.target as Node) ||166 isInsideUpperLayer(event.target)167 ) {168 return;169 }170 handler();171 };172 document.addEventListener('pointerdown', handlePointerDownOutside);173 return () => document.removeEventListener('pointerdown', handlePointerDownOutside);174 }, [ref, triggerRef, handler, enabled, isInsideUpperLayer]);175}176177function getMenuItems(container: HTMLElement | null): HTMLElement[] {178 if (!container) return [];179 return Array.from(180 container.querySelectorAll<HTMLElement>(181 '[role="menuitem"]:not([aria-disabled="true"]), [role="menuitemcheckbox"]:not([aria-disabled="true"]), [role="menuitemradio"]:not([aria-disabled="true"])',182 ),183 );184}185186function focusEdge(container: HTMLElement | null, edge: FocusEdge) {187 const items = getMenuItems(container);188 (edge === 'last' ? items[items.length - 1] : items[0])?.focus();189}190191function useTypeahead() {192 const bufferRef = React.useRef('');193 const timerRef = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);194195 React.useEffect(() => () => clearTimeout(timerRef.current), []);196197 const isTyping = React.useCallback(() => bufferRef.current !== '', []);198199 const search = React.useCallback((key: string, items: HTMLElement[], current: number) => {200 clearTimeout(timerRef.current);201 timerRef.current = setTimeout(() => {202 bufferRef.current = '';203 }, TYPEAHEAD_RESET_MS);204 const buffer = bufferRef.current + key.toLowerCase();205 bufferRef.current = buffer;206 const repeated = Array.from(buffer).every((char) => char === buffer[0]);207 const query = repeated ? buffer.charAt(0) : buffer;208 const start = repeated ? current + 1 : Math.max(current, 0);209 const ordered = [...items.slice(start), ...items.slice(0, start)];210 return ordered.find((item) => item.textContent?.trim().toLowerCase().startsWith(query));211 }, []);212213 return React.useMemo(() => ({ search, isTyping }), [search, isTyping]);214}215216const DropdownMenuRoot = ({217 children,218 className,219 defaultOpen = false,220 open: controlledOpen,221 onOpenChange,222}: DropdownMenuProps): React.JSX.Element => {223 const [isOpen, setOpen] = useControllableState({224 value: controlledOpen,225 defaultValue: defaultOpen,226 onChange: onOpenChange,227 });228 const triggerRef = React.useRef<HTMLButtonElement>(null);229 const contentRef = React.useRef<HTMLDivElement>(null);230 const initialFocusRef = React.useRef<FocusEdge>('first');231232 const openMenu = React.useCallback(233 (focus: FocusEdge = 'first') => {234 initialFocusRef.current = focus;235 setOpen(true);236 },237 [setOpen],238 );239240 const closeMenu = React.useCallback(241 (options?: { restoreFocus?: boolean }) => {242 setOpen(false);243 if (options?.restoreFocus !== false) {244 triggerRef.current?.focus();245 }246 },247 [setOpen],248 );249250 const toggleMenu = React.useCallback(() => {251 initialFocusRef.current = 'first';252 setOpen(!isOpen);253 }, [setOpen, isOpen]);254255 const id = React.useId();256 const triggerId = `dropdown-trigger-${id}`;257 const contentId = `dropdown-content-${id}`;258259 const contextValue = React.useMemo(260 () => ({261 isOpen,262 openMenu,263 closeMenu,264 toggleMenu,265 initialFocusRef,266 triggerRef,267 contentRef,268 triggerId,269 contentId,270 }),271 [isOpen, openMenu, closeMenu, toggleMenu, triggerId, contentId],272 );273274 return (275 <DropdownContext value={contextValue}>276 <div className={cn('relative inline-block text-left', className)}>{children}</div>277 </DropdownContext>278 );279};280281const DropdownMenuTrigger = ({282 children,283 onClick,284 className,285 asChild = false,286}: DropdownMenuTriggerProps): React.JSX.Element => {287 const { isOpen, toggleMenu, openMenu, triggerRef, contentRef, triggerId, contentId } =288 useDropdownContext();289 const shouldReduceMotion = useReducedMotion();290291 const handleClick = React.useCallback(292 (e: React.MouseEvent<HTMLButtonElement>) => {293 toggleMenu();294 onClick?.(e);295 },296 [toggleMenu, onClick],297 );298299 const handleKeyDown = React.useCallback(300 (e: React.KeyboardEvent<HTMLButtonElement>) => {301 if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {302 e.preventDefault();303 const edge = e.key === 'ArrowUp' ? 'last' : 'first';304 if (isOpen) focusEdge(contentRef.current, edge);305 else openMenu(edge);306 }307 },308 [isOpen, openMenu, contentRef],309 );310311 const triggerA11yProps = {312 'aria-expanded': isOpen,313 'aria-haspopup': 'menu' as const,314 'aria-controls': isOpen ? contentId : undefined,315 id: triggerId,316 };317318 if (asChild && React.isValidElement(children)) {319 const childProps = children.props as {320 onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;321 onKeyDown?: (e: React.KeyboardEvent<HTMLButtonElement>) => void;322 };323324 return React.cloneElement(children, {325 ref: triggerRef,326 onClick: (e: React.MouseEvent<HTMLButtonElement>) => {327 childProps.onClick?.(e);328 handleClick(e);329 },330 onKeyDown: (e: React.KeyboardEvent<HTMLButtonElement>) => {331 childProps.onKeyDown?.(e);332 handleKeyDown(e);333 },334 ...triggerA11yProps,335 } as React.HTMLAttributes<HTMLElement>);336 }337338 return (339 <motion.button340 ref={triggerRef}341 type="button"342 onClick={handleClick}343 onKeyDown={handleKeyDown}344 whileTap={!shouldReduceMotion ? tap : undefined}345 transition={springs.snappy}346 className={cn(347 'inline-flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-medium',348 'text-foreground border-border border',349 'hover:bg-muted transition-colors',350 'ring-offset-background focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',351 className,352 )}353 {...triggerA11yProps}354 >355 {children}356 <motion.span357 aria-hidden="true"358 variants={DROPDOWN_ICON_VARIANTS}359 animate={isOpen ? 'open' : 'closed'}360 transition={springs.snappy}361 style={DROPDOWN_ICON_STYLE}362 >363 <ChevronDownIcon className="h-4 w-4 opacity-50" size={16} />364 </motion.span>365 </motion.button>366 );367};368369const DropdownMenuContent = ({370 children,371 className,372 align = 'start',373 sideOffset = 6,374}: DropdownMenuContentProps): React.JSX.Element | null => {375 const { isOpen, closeMenu, contentId, triggerId, triggerRef, contentRef, initialFocusRef } =376 useDropdownContext();377 const layer = useLayer(isOpen, false, contentRef);378 const typeahead = useTypeahead();379 const shouldReduceMotion = useReducedMotion();380 const { style: floatingStyle, side } = useFloating({381 open: isOpen,382 anchorRef: triggerRef,383 floatingRef: contentRef,384 side: 'bottom',385 align,386 sideOffset,387 });388 const verticalSide = side === 'top' ? 'top' : 'bottom';389390 const closeWithoutFocus = React.useCallback(391 () => closeMenu({ restoreFocus: false }),392 [closeMenu],393 );394395 useClickOutside(contentRef, triggerRef, closeWithoutFocus, isOpen, layer.isInsideUpperLayer);396397 React.useEffect(() => {398 if (!isOpen) return;399 const frame = requestAnimationFrame(() => {400 focusEdge(contentRef.current, initialFocusRef.current);401 });402 return () => cancelAnimationFrame(frame);403 }, [isOpen, contentRef, initialFocusRef]);404405 const handleKeyDown = React.useCallback(406 (e: React.KeyboardEvent<HTMLDivElement>) => {407 if (e.key === 'Escape') {408 if (!layer.isTopmost()) return;409 e.preventDefault();410 closeMenu();411 return;412 }413 if (e.key === 'Tab') {414 closeMenu();415 return;416 }417418 const items = getMenuItems(contentRef.current);419 if (items.length === 0) return;420421 const currentIndex = items.indexOf(document.activeElement as HTMLElement);422423 let nextIndex: number | null = null;424 if (e.key === 'ArrowDown') {425 nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % items.length;426 } else if (e.key === 'ArrowUp') {427 nextIndex =428 currentIndex < 0 ? items.length - 1 : (currentIndex - 1 + items.length) % items.length;429 } else if (e.key === 'Home') {430 nextIndex = 0;431 } else if (e.key === 'End') {432 nextIndex = items.length - 1;433 } else if (434 e.key.length === 1 &&435 !e.altKey &&436 !e.ctrlKey &&437 !e.metaKey &&438 (e.key !== ' ' || typeahead.isTyping())439 ) {440 e.preventDefault();441 e.stopPropagation();442 typeahead.search(e.key, items, currentIndex)?.focus();443 return;444 }445446 if (nextIndex !== null) {447 e.preventDefault();448 items[nextIndex]?.focus();449 }450 },451 [closeMenu, contentRef, layer, typeahead],452 );453454 const style = React.useMemo(455 () => ({456 ...floatingStyle,457 ...(isOpen ? null : { visibility: undefined }),458 ...DROPDOWN_CONTENT_STYLE,459 }),460 [floatingStyle, isOpen],461 );462463 return (464 <FloatingPortal>465 <AnimatePresence>466 {isOpen && (467 <motion.div468 ref={contentRef}469 id={contentId}470 role="menu"471 tabIndex={-1}472 aria-labelledby={triggerId}473 onKeyDownCapture={handleKeyDown}474 variants={shouldReduceMotion ? reveal : floatingVariants[verticalSide]}475 initial="hidden"476 animate="visible"477 exit="exit"478 style={style}479 className={cn(480 'bg-popover text-popover-foreground border-border z-500 max-h-(--available-height) min-w-48 overflow-y-auto overscroll-contain rounded-md border shadow-md outline-none',481 ORIGIN_CLASSES[verticalSide][align],482 className,483 )}484 >485 <div className="flex flex-col gap-0.5 p-1">{children}</div>486 </motion.div>487 )}488 </AnimatePresence>489 </FloatingPortal>490 );491};492493const itemClassName = (disabled: boolean, variant: 'default' | 'destructive') =>494 cn(495 'relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none',496 'transition-colors duration-150',497 'focus-visible:bg-muted focus:bg-muted pointer-coarse:py-2.5',498 disabled && 'pointer-events-none opacity-40',499 variant === 'destructive' && 'text-destructive focus:text-destructive',500 );501502const DropdownMenuItem = ({503 children,504 onClick,505 className,506 disabled = false,507 variant = 'default',508 onSelect,509 asChild = false,510 role = 'menuitem',511 checked,512 closeOnSelect = role === 'menuitem',513}: DropdownMenuItemProps): React.JSX.Element => {514 const { closeMenu } = useDropdownContext();515 const checkable = role !== 'menuitem';516 const ariaChecked = checkable ? Boolean(checked) : undefined;517518 const handleClick = React.useCallback(519 (e: React.MouseEvent<HTMLDivElement>) => {520 if (disabled) return;521 e.stopPropagation();522 onClick?.(e);523 onSelect?.();524 if (closeOnSelect) closeMenu();525 },526 [disabled, onClick, onSelect, closeMenu, closeOnSelect],527 );528529 const handleKeyDown = React.useCallback(530 (e: React.KeyboardEvent<HTMLDivElement>) => {531 if (disabled) return;532 if (e.key !== 'Enter' && e.key !== ' ') return;533 e.stopPropagation();534 const target = e.currentTarget as HTMLElement;535 if (target instanceof HTMLAnchorElement && target.hasAttribute('href')) {536 if (e.key === ' ') {537 e.preventDefault();538 target.click();539 }540 return;541 }542 e.preventDefault();543 onClick?.(e as unknown as React.MouseEvent<HTMLDivElement>);544 onSelect?.();545 if (closeOnSelect) closeMenu();546 },547 [disabled, onClick, onSelect, closeMenu, closeOnSelect],548 );549550 const handlePointerMove = React.useCallback(551 (e: React.PointerEvent<HTMLElement>) => {552 if (disabled || e.pointerType === 'touch') return;553 if (document.activeElement !== e.currentTarget) e.currentTarget.focus();554 },555 [disabled],556 );557558 const handlePointerLeave = React.useCallback((e: React.PointerEvent<HTMLElement>) => {559 if (document.activeElement !== e.currentTarget) return;560 e.currentTarget.closest<HTMLElement>('[role="menu"]')?.focus();561 }, []);562563 if (asChild && React.isValidElement(children)) {564 const childProps = children.props as Record<string, unknown>;565 const childOnClick = childProps.onClick as566 | ((e: React.MouseEvent<HTMLDivElement>) => void)567 | undefined;568 const childOnKeyDown = childProps.onKeyDown as569 | ((e: React.KeyboardEvent<HTMLDivElement>) => void)570 | undefined;571 const childOnPointerMove = childProps.onPointerMove as572 | ((e: React.PointerEvent<HTMLElement>) => void)573 | undefined;574 const childOnPointerLeave = childProps.onPointerLeave as575 | ((e: React.PointerEvent<HTMLElement>) => void)576 | undefined;577578 return React.cloneElement(children, {579 onClick: (e: React.MouseEvent<HTMLDivElement>) => {580 childOnClick?.(e);581 handleClick(e);582 },583 onKeyDown: (e: React.KeyboardEvent<HTMLDivElement>) => {584 childOnKeyDown?.(e);585 handleKeyDown(e);586 },587 onPointerMove: (e: React.PointerEvent<HTMLElement>) => {588 childOnPointerMove?.(e);589 handlePointerMove(e);590 },591 onPointerLeave: (e: React.PointerEvent<HTMLElement>) => {592 childOnPointerLeave?.(e);593 handlePointerLeave(e);594 },595 role,596 tabIndex: -1,597 'aria-disabled': disabled || undefined,598 'aria-checked': ariaChecked,599 className: cn(600 itemClassName(disabled, variant),601 className,602 childProps.className as string | undefined,603 ),604 style: childProps.style as React.CSSProperties | undefined,605 } as React.ComponentProps<'div'>);606 }607608 return (609 <div610 role={role}611 tabIndex={-1}612 aria-disabled={disabled || undefined}613 aria-checked={ariaChecked}614 onClick={handleClick}615 onKeyDown={handleKeyDown}616 onPointerMove={handlePointerMove}617 onPointerLeave={handlePointerLeave}618 className={cn(itemClassName(disabled, variant), className)}619 >620 {children}621 </div>622 );623};624625const DropdownMenuCheckboxItem = ({626 checked = false,627 onCheckedChange,628 onSelect,629 children,630 className,631 ...props632}: DropdownMenuCheckableItemProps) => (633 <DropdownMenuItem634 {...props}635 role="menuitemcheckbox"636 checked={checked}637 className={cn('pl-8', className)}638 onSelect={() => {639 onCheckedChange?.(!checked);640 onSelect?.();641 }}642 >643 <span className="absolute left-2 flex size-4 items-center justify-center">644 {checked && <CheckIcon size={14} />}645 </span>646 {children}647 </DropdownMenuItem>648);649650const DropdownMenuRadioItem = ({651 checked = false,652 onCheckedChange,653 onSelect,654 children,655 className,656 closeOnSelect = true,657 ...props658}: DropdownMenuCheckableItemProps) => (659 <DropdownMenuItem660 {...props}661 role="menuitemradio"662 checked={checked}663 closeOnSelect={closeOnSelect}664 className={cn('pl-8', className)}665 onSelect={() => {666 if (!checked) onCheckedChange?.(true);667 onSelect?.();668 }}669 >670 <span className="absolute left-2 flex size-4 items-center justify-center">671 {checked && <DotIcon size={14} />}672 </span>673 {children}674 </DropdownMenuItem>675);676677const DropdownLabel = ({678 children,679 className,680}: {681 children: React.ReactNode;682 className?: string;683}) => {684 return (685 <div686 role="presentation"687 className={cn(688 'text-muted-foreground px-2 py-2 text-xs font-semibold tracking-wider uppercase',689 className,690 )}691 >692 {children}693 </div>694 );695};696697const DropdownSeparator = ({ className }: { className?: string }) => {698 return (699 <div700 role="separator"701 aria-orientation="horizontal"702 className={cn('bg-border/50 my-1 h-px', className)}703 />704 );705};706707const DropdownMenu = Object.assign(DropdownMenuRoot, {708 Trigger: DropdownMenuTrigger,709 Content: DropdownMenuContent,710 Item: DropdownMenuItem,711 CheckboxItem: DropdownMenuCheckboxItem,712 RadioItem: DropdownMenuRadioItem,713 Label: DropdownLabel,714 Separator: DropdownSeparator,715});716717export { DropdownMenu };718export type {719 DropdownMenuCheckableItemProps,720 DropdownMenuContentProps,721 DropdownMenuItemProps,722 DropdownMenuProps,723 DropdownMenuTriggerProps,724};
Update the import paths to match your project structure.
Anatomy
1import { DropdownMenu } from '@/components/ui/dropdown-menu';
1<DropdownMenu>2 <DropdownMenu.Trigger asChild>3 <Button variant="outline">Open Menu</Button>4 </DropdownMenu.Trigger>5 <DropdownMenu.Content>6 <DropdownMenu.Label>My Account</DropdownMenu.Label>7 <DropdownMenu.Item>Profile</DropdownMenu.Item>8 <DropdownMenu.Item>Settings</DropdownMenu.Item>9 <DropdownMenu.Separator />10 <DropdownMenu.Item variant="destructive">Logout</DropdownMenu.Item>11 </DropdownMenu.Content>12</DropdownMenu>
Composition
Use the following composition to build a
DropdownMenu:DropdownMenuโโโ DropdownMenu.Triggerโโโ DropdownMenu.Contentโโโ DropdownMenu.Labelโโโ DropdownMenu.Itemโโโ DropdownMenu.Separator
Variants
Checkboxes
1'use client';23import * as React from 'react';4import { Button } from '@/components/ui/button';5import { DropdownMenu } from '@/components/ui/dropdown-menu';67const COLUMNS = [8 { id: 'customer', label: 'Customer' },9 { id: 'status', label: 'Status' },10 { id: 'amount', label: 'Amount' },11 { id: 'method', label: 'Payment method' },12 { id: 'created', label: 'Created' },13] as const;1415const INITIAL_STATE: Record<string, boolean> = {16 customer: true,17 status: true,18 amount: true,19 method: false,20 created: false,21};2223export function Checkboxes() {24 const [visible, setVisible] = React.useState<Record<string, boolean>>(INITIAL_STATE);2526 const toggle = (id: string) => setVisible((prev) => ({ ...prev, [id]: !prev[id] }));27 const shownCount = COLUMNS.filter((column) => visible[column.id]).length;2829 return (30 <DropdownMenu>31 <DropdownMenu.Trigger asChild>32 <Button variant="outline">33 Columns ({shownCount}/{COLUMNS.length})34 </Button>35 </DropdownMenu.Trigger>36 <DropdownMenu.Content className="w-56 max-w-[calc(100vw-2rem)]" align="start">37 <DropdownMenu.Label>Show in orders table</DropdownMenu.Label>38 <DropdownMenu.Separator />39 {COLUMNS.map(({ id, label }) => (40 <DropdownMenu.CheckboxItem41 key={id}42 checked={Boolean(visible[id])}43 onCheckedChange={() => toggle(id)}44 >45 {label}46 </DropdownMenu.CheckboxItem>47 ))}48 </DropdownMenu.Content>49 </DropdownMenu>50 );51}
Radio Group
1'use client';23import * as React from 'react';4import { Button } from '@/components/ui/button';5import { DropdownMenu } from '@/components/ui/dropdown-menu';67const SORT_OPTIONS = [8 { value: 'newest', label: 'Newest first' },9 { value: 'oldest', label: 'Oldest first' },10 { value: 'amount-desc', label: 'Amount, high to low' },11 { value: 'amount-asc', label: 'Amount, low to high' },12] as const;1314export function RadioGroup() {15 const [sort, setSort] = React.useState<string>('newest');16 const active = SORT_OPTIONS.find((option) => option.value === sort);1718 return (19 <DropdownMenu>20 <DropdownMenu.Trigger asChild>21 <Button variant="outline">Sort: {active?.label}</Button>22 </DropdownMenu.Trigger>23 <DropdownMenu.Content className="w-56 max-w-[calc(100vw-2rem)]" align="start">24 <DropdownMenu.Label>Sort 240 orders by</DropdownMenu.Label>25 <DropdownMenu.Separator />26 {SORT_OPTIONS.map((option) => (27 <DropdownMenu.RadioItem28 key={option.value}29 checked={sort === option.value}30 onCheckedChange={() => setSort(option.value)}31 >32 {option.label}33 </DropdownMenu.RadioItem>34 ))}35 </DropdownMenu.Content>36 </DropdownMenu>37 );38}
API Reference
DropdownMenu
| Prop | Type | Default | Description |
|---|---|---|---|
defaultOpen | boolean | false | Initial open state |
onOpenChange | (open: boolean) => void | - | Callback when open state changes |
className | string | - | Additional CSS classes |
DropdownMenu.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Render as child element |
className | string | - | Additional CSS classes |
DropdownMenu.Content
| Prop | Type | Default | Description |
|---|---|---|---|
align | 'start' | 'center' | 'end' | 'start' | Horizontal alignment |
sideOffset | number | 6 | Distance from trigger |
className | string | - | Additional CSS classes |
DropdownMenu.Item
| Prop | Type | Default | Description |
|---|---|---|---|
disabled | boolean | false | Disable the item |
variant | 'default' | 'destructive' | 'default' | Visual style |
onSelect | () => void | - | Callback when selected |
closeOnSelect | boolean | true | Close the menu after selecting |
className | string | - | Additional CSS classes |
DropdownMenu.CheckboxItem
An item with
role="menuitemcheckbox" that shows a check when it's on. It keeps the menu open after a toggle, so people can switch several options in one go.| Prop | Type | Default | Description |
|---|---|---|---|
checked | boolean | false | Whether the option is on |
onCheckedChange | (checked: boolean) => void | - | Called with the new value |
closeOnSelect | boolean | false | Close the menu after toggling |
disabled | boolean | false | Disable the item |
className | string | - | Additional CSS classes |
DropdownMenu.RadioItem
An item with
role="menuitemradio" for picking one option out of a set. Render one per option and mark the current one with checked. It closes the menu by default.| Prop | Type | Default | Description |
|---|---|---|---|
checked | boolean | false | Whether this is the selected option |
onCheckedChange | (checked: boolean) => void | - | Called with true when the option is picked |
closeOnSelect | boolean | true | Close the menu after picking |
disabled | boolean | false | Disable the item |
className | string | - | Additional CSS classes |
DropdownMenu.Label
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | - | Label text |
DropdownSeparator
Visual separator between menu items.
No props - renders a horizontal line.
Found something to improve?
Notice a bug, typo, or missing detail on this page? Help us make the documentation better by opening a GitHub issue.