Dropdown Menu
A menu with icons, shortcuts, danger items and separators — arrow-key navigation and click-outside close.
Last action: —
'use client';
import { useState } from 'react';
import { Archive, Copy, DotsThree, PencilSimple, ShareNetwork, Star, Trash } from '@phosphor-icons/react';
import { Button } from '@/components/ui/button';
import { DropdownMenu, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';
export default function DropdownMenuDemo() {
const [last, setLast] = useState('—');
const [starred, setStarred] = useState(false);
return (
<div className="flex flex-col items-center gap-4">
<DropdownMenu
aria-label="Note actions"
trigger={
<Button variant="secondary">
Note actions <DotsThree weight="bold" />
</Button>
}
className="w-56"
>
<DropdownMenuLabel>Weekly sync 10/18</DropdownMenuLabel>
<DropdownMenuItem icon={<PencilSimple />} shortcut="R" onSelect={() => setLast('Rename')}>Rename</DropdownMenuItem>
<DropdownMenuItem icon={<Copy />} shortcut="⌘D" onSelect={() => setLast('Duplicate')}>Duplicate</DropdownMenuItem>
<DropdownMenuItem icon={<ShareNetwork />} onSelect={() => setLast('Share')}>Share</DropdownMenuItem>
<DropdownMenuItem icon={<Star />} checked={starred} onSelect={() => setStarred((s) => !s)}>Star</DropdownMenuItem>
<DropdownMenuItem icon={<Archive />} disabled>Archive</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem icon={<Trash />} shortcut="⌫" danger onSelect={() => setLast('Delete')}>Delete</DropdownMenuItem>
</DropdownMenu>
<p className="text-[13px] text-text-muted">Last action: {last}</p>
</div>
);
}
Installation
'use client';
import { AnimatePresence, motion } from 'motion/react';
import { Check } from '@phosphor-icons/react';
import {
cloneElement,
createContext,
isValidElement,
useCallback,
useContext,
useEffect,
useId,
useRef,
useState,
type CSSProperties,
type HTMLAttributes,
type KeyboardEvent,
type MouseEvent as ReactMouseEvent,
type ReactElement,
type ReactNode,
} from 'react';
import { twMerge } from 'tailwind-merge';
import { SPRING_SNAPPY } from '@/lib/motion';
import { useReducedMotionPref } from '@/lib/use-reduced-motion';
import { Portal, useLayer } from './overlay';
/* ==========================================================================
DropdownMenu:触发器 + 菜单项(图标 / 文本 / 快捷键 / 危险)+ 分隔线
↑↓ / Home / End / 首字母跳转,Esc 与点外部关闭,SNAPPY 弹簧
========================================================================== */
type Ctx = { close: (focusTrigger?: boolean) => void };
const MenuCtx = createContext<Ctx | null>(null);
export interface DropdownMenuProps {
/** 单个按钮元素;会注入 aria-haspopup / aria-expanded / onClick / onKeyDown */
trigger: ReactElement;
children: ReactNode;
/** 与触发器的水平对齐 */
align?: 'start' | 'end';
side?: 'bottom' | 'top';
/** 受控 */
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** 菜单面板类名(如 w-56) */
className?: string;
'aria-label'?: string;
}
const ITEM_SEL = '[role^="menuitem"]:not([aria-disabled="true"])';
export function DropdownMenu({
trigger,
children,
align = 'start',
side = 'bottom',
open: openProp,
onOpenChange,
className,
'aria-label': ariaLabel,
}: DropdownMenuProps) {
const [inner, setInner] = useState(false);
const open = openProp ?? inner;
const setOpen = useCallback(
(o: boolean) => {
if (openProp === undefined) setInner(o);
onOpenChange?.(o);
},
[openProp, onOpenChange],
);
const anchor = useRef<HTMLSpanElement>(null);
const menu = useRef<HTMLDivElement>(null);
const focusFirst = useRef<'first' | 'last' | null>(null);
const [style, setStyle] = useState<CSSProperties>({});
const menuId = useId();
const reduce = useReducedMotionPref();
const triggerEl = () => (anchor.current?.firstElementChild as HTMLElement | null) ?? anchor.current;
const setOpenRef = useRef(setOpen);
setOpenRef.current = setOpen;
const close = useCallback((focusTrigger = true) => {
setOpenRef.current(false);
if (focusTrigger) requestAnimationFrame(() => triggerEl()?.focus());
}, []);
useLayer(open, () => close(true));
const measure = useCallback(() => {
const el = triggerEl();
if (!el) return;
const r = el.getBoundingClientRect();
const s: CSSProperties = {};
if (side === 'bottom') s.top = r.bottom + 6;
else s.bottom = window.innerHeight - r.top + 6;
if (align === 'start') s.left = Math.max(8, r.left);
else s.right = Math.max(8, window.innerWidth - r.right);
setStyle(s);
}, [align, side]);
useEffect(() => {
if (!open) return;
measure();
const onDown = (e: PointerEvent) => {
const t = e.target as Node;
if (menu.current?.contains(t) || anchor.current?.contains(t)) return;
close(false);
};
const onResize = () => close(false);
document.addEventListener('pointerdown', onDown, true);
window.addEventListener('scroll', measure, true);
window.addEventListener('resize', onResize);
const raf = requestAnimationFrame(() => {
const items = menu.current?.querySelectorAll<HTMLElement>(ITEM_SEL);
if (!items?.length) return menu.current?.focus();
const which = focusFirst.current;
focusFirst.current = null;
if (which === 'last') items[items.length - 1].focus();
else if (which === 'first') items[0].focus();
else menu.current?.focus();
});
return () => {
cancelAnimationFrame(raf);
document.removeEventListener('pointerdown', onDown, true);
window.removeEventListener('scroll', measure, true);
window.removeEventListener('resize', onResize);
};
}, [open, measure, close]);
const onMenuKey = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
const items = Array.from(menu.current?.querySelectorAll<HTMLElement>(ITEM_SEL) ?? []);
if (!items.length) return;
const i = items.indexOf(document.activeElement as HTMLElement);
const go = (n: number) => items[(n + items.length) % items.length]?.focus();
if (e.key === 'ArrowDown') {
e.preventDefault();
go(i + 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
go(i < 0 ? items.length - 1 : i - 1);
} else if (e.key === 'Home') {
e.preventDefault();
go(0);
} else if (e.key === 'End') {
e.preventDefault();
go(items.length - 1);
} else if (e.key === 'Tab') {
e.preventDefault();
close(true);
} else if (e.key.length === 1 && /\S/.test(e.key) && !e.metaKey && !e.ctrlKey) {
const k = e.key.toLowerCase();
const order = [...items.slice(i + 1), ...items.slice(0, i + 1)];
order.find((el) => (el.textContent ?? '').trim().toLowerCase().startsWith(k))?.focus();
}
};
const t = trigger as ReactElement<{
onClick?: (e: ReactMouseEvent) => void;
onKeyDown?: (e: KeyboardEvent) => void;
}>;
const triggerNode = isValidElement(trigger)
? cloneElement(t, {
'aria-haspopup': 'menu',
'aria-expanded': open,
'aria-controls': open ? menuId : undefined,
'data-state': open ? 'open' : 'closed',
onClick: (e: ReactMouseEvent) => {
t.props.onClick?.(e);
if (!e.defaultPrevented) setOpen(!open);
},
onKeyDown: (e: KeyboardEvent) => {
t.props.onKeyDown?.(e);
if (e.defaultPrevented) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
focusFirst.current = e.key === 'ArrowUp' ? 'last' : 'first';
if (open) {
const items = menu.current?.querySelectorAll<HTMLElement>(ITEM_SEL);
items?.[e.key === 'ArrowUp' ? items.length - 1 : 0]?.focus();
} else setOpen(true);
} else if ((e.key === 'Enter' || e.key === ' ') && !open) {
focusFirst.current = 'first';
}
},
} as Record<string, unknown>)
: trigger;
const origin = `${side === 'bottom' ? 'top' : 'bottom'} ${align === 'start' ? 'left' : 'right'}`;
return (
<MenuCtx.Provider value={{ close }}>
<span ref={anchor} className="inline-flex">
{triggerNode}
</span>
<Portal>
<AnimatePresence>
{open && (
<motion.div
ref={menu}
id={menuId}
role="menu"
aria-label={ariaLabel}
tabIndex={-1}
onKeyDown={onMenuKey}
style={{ ...style, transformOrigin: origin }}
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: side === 'bottom' ? -4 : 4 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: reduce ? 1 : 0.98, transition: { duration: reduce ? 0 : 0.1 } }}
transition={reduce ? { duration: 0 } : SPRING_SNAPPY}
className={twMerge(
'fixed z-[60] min-w-[200px] max-w-[320px] rounded-xl bg-popover-surface p-1 text-text-primary shadow-popover outline-none',
className,
)}
>
{children}
</motion.div>
)}
</AnimatePresence>
</Portal>
</MenuCtx.Provider>
);
}
export interface DropdownMenuItemProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onSelect'> {
icon?: ReactNode;
/** 右侧快捷键文本,如 "⌘D" */
shortcut?: ReactNode;
danger?: boolean;
disabled?: boolean;
/** 设置后变为 menuitemcheckbox,选中时显示对勾 */
checked?: boolean;
/** 选择回调;调用 e.preventDefault() 可阻止菜单关闭 */
onSelect?: (e: Event) => void;
}
export function DropdownMenuItem({
icon,
shortcut,
danger,
disabled,
checked,
onSelect,
className,
children,
...p
}: DropdownMenuItemProps) {
const ctx = useContext(MenuCtx);
const select = () => {
if (disabled) return;
const ev = new Event('select', { cancelable: true });
onSelect?.(ev);
if (!ev.defaultPrevented) ctx?.close(true);
};
const isCheck = checked !== undefined;
return (
<div
role={isCheck ? 'menuitemcheckbox' : 'menuitem'}
aria-checked={isCheck ? checked : undefined}
aria-disabled={disabled || undefined}
tabIndex={-1}
data-danger={danger || undefined}
onClick={select}
onKeyDown={(e) => {
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
select();
}
}}
onPointerMove={(e) => !disabled && e.currentTarget !== document.activeElement && e.currentTarget.focus({ preventScroll: true })}
className={twMerge(
'flex h-8 cursor-default select-none items-center gap-2.5 rounded-lg px-2 text-sm outline-none',
'text-text-primary transition-colors duration-[70ms] focus:bg-ghost-hover',
'[&_svg]:size-4 [&_svg]:shrink-0',
danger && 'text-danger focus:bg-danger/10',
disabled && 'pointer-events-none opacity-45',
className,
)}
{...p}
>
{icon != null && <span className={twMerge('flex items-center', danger ? 'text-danger' : 'text-text-secondary')}>{icon}</span>}
<span className="min-w-0 flex-1 truncate">{children}</span>
{isCheck && <Check weight="bold" className={twMerge('text-accent-text', !checked && 'invisible')} />}
{shortcut != null && (
<span className={twMerge('ml-3 text-[12px] tabular-nums', danger ? 'text-danger/70' : 'text-text-muted')}>{shortcut}</span>
)}
</div>
);
}
export function DropdownMenuSeparator({ className, ...p }: HTMLAttributes<HTMLDivElement>) {
return <div role="separator" className={twMerge('mx-1 my-1 h-px bg-hairline', className)} {...p} />;
}
/** 分组小标题(不可聚焦) */
export function DropdownMenuLabel({ className, ...p }: HTMLAttributes<HTMLDivElement>) {
return <div className={twMerge('px-2 pb-1 pt-1.5 text-[12px] font-medium text-text-muted', className)} {...p} />;
}
Usage
import { DropdownMenu, DropdownMenuItem, DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
<DropdownMenu trigger={<Button variant="ghost" size="icon" aria-label="More"><DotsThree /></Button>} align="end">
<DropdownMenuItem icon={<PencilSimple />} shortcut="R" onSelect={rename}>Rename</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem icon={<Trash />} danger onSelect={remove}>Delete</DropdownMenuItem>
</DropdownMenu>| Prop | Type | Default | Notes |
|---|---|---|---|
trigger | ReactElement | — | Trigger button |
align / side | start end / bottom top | start / bottom | Alignment and side |
open / onOpenChange | — | — | Optional control |
Item icon / shortcut | ReactNode | — | Icon / shortcut |
Item danger / disabled / checked | boolean | — | Danger / disabled / checkbox |
Item onSelect | (e: Event) => void | — | e.preventDefault() keeps it open |