#CSS Anchor 下拉菜单
使用 CSS Anchor Positioning 实现精准定位的下拉菜单,支持子菜单与自动翻转。
预览
dropdown.demo.tsx
基础下拉菜单
嵌套子菜单
悬停在菜单项上可以看到子菜单。子菜单使用 CSS Anchor 定位到父级菜单项的右侧。
/** biome-ignore-all lint/suspicious/noArrayIndexKey: <explanation> */
import React, { useEffect, useRef, useState } from 'react';
import {
Bell,
Copy,
Download,
File,
Folder,
HelpCircle,
Layout,
LogOut,
Settings,
Share2,
Trash2,
Upload,
User,
} from 'lucide-react';
interface MenuItem {
icon: React.ReactNode;
label: string;
shortcut?: string;
danger?: boolean;
divider?: boolean;
}
const fileMenuItems: MenuItem[] = [
{ icon: <File className="h-4 w-4" />, label: '新建文件', shortcut: '⌘N' },
{ icon: <Folder className="h-4 w-4" />, label: '新建文件夹', shortcut: '⇧⌘N' },
{ divider: true, icon: null as any, label: '' },
{ icon: <Upload className="h-4 w-4" />, label: '上传文件' },
{ icon: <Download className="h-4 w-4" />, label: '下载', shortcut: '⌘S' },
{ divider: true, icon: null as any, label: '' },
{ icon: <Share2 className="h-4 w-4" />, label: '分享', shortcut: '⌘⇧S' },
{ icon: <Copy className="h-4 w-4" />, label: '复制链接' },
{ divider: true, icon: null as any, label: '' },
{ icon: <Trash2 className="h-4 w-4" />, label: '删除', shortcut: '⌘⌫', danger: true },
];
const userMenuItems: MenuItem[] = [
{ icon: <User className="h-4 w-4" />, label: '个人资料' },
{ icon: <Settings className="h-4 w-4" />, label: '设置', shortcut: '⌘,' },
{ icon: <Bell className="h-4 w-4" />, label: '通知' },
{ divider: true, icon: null as any, label: '' },
{ icon: <HelpCircle className="h-4 w-4" />, label: '帮助中心' },
{ icon: <LogOut className="h-4 w-4" />, label: '退出登录', danger: true },
];
function AnchorDropdown({
trigger,
items,
align = 'start',
}: {
trigger: React.ReactNode;
items: MenuItem[];
align?: 'start' | 'end';
}) {
const [isOpen, setIsOpen] = useState(false);
const id = useRef(`dropdown-${Math.random().toString(36).substr(2, 9)}`).current;
const anchorName = `--anchor-${id}`;
const dropdownRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
triggerRef.current &&
!triggerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen]);
return (
<div className="relative">
<button
ref={triggerRef}
type="button"
onClick={() => setIsOpen(!isOpen)}
className="rd-lg inline-flex items-center gap-2 border border-gray-3 bg-white px-4 py-2 transition-colors hover:bg-gray-1 dark:border-[#424242] dark:bg-[#262626] dark:hover:bg-[#404040]"
style={{ anchorName } as React.CSSProperties}
>
{trigger}
<svg
className={`h-4 w-4 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
<div
ref={dropdownRef}
className={`rd-xl fixed z-50 min-w-50 origin-top border border-gray-3 bg-white py-1.5 shadow-xl transition-all duration-200 dark:border-[#424242] dark:bg-[#262626] ${
isOpen ? 'scale-100 opacity-100' : 'pointer-events-none scale-95 opacity-0'
}`}
style={
{
positionAnchor: anchorName,
top: 'anchor(bottom)',
...(align === 'start' ? { left: 'anchor(left)' } : { right: 'anchor(right)' }),
translate: '0 8px',
positionTryFallbacks: 'flip-block',
} as React.CSSProperties
}
>
{items.map((item, index) =>
item.divider ? (
<div key={index} className="my-1.5 border-gray-3 border-t dark:border-[#424242]" />
) : (
<button
key={index}
type="button"
className={`flex w-full items-center gap-3 bg-[#fff] px-3 py-2 text-sm transition-colors dark:bg-#00000005 ${
item.danger ? 'text-error hover:bg-error-1 dark:hover:bg-[#7f1d1d]' : 'hover:bg-gray-1 dark:hover:bg-[#424242]'
}`}
onClick={() => setIsOpen(false)}
>
<span className="h-4 w-4 opacity-60">{item.icon}</span>
<span className="flex-1 text-left">{item.label}</span>
{item.shortcut && <span className="text-gray-5 text-xs dark:text-white-6">{item.shortcut}</span>}
</button>
),
)}
</div>
</div>
);
}
function NestedDropdown() {
const [isOpen, setIsOpen] = useState(false);
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
const anchorName = '--anchor-nested-main';
const dropdownRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
triggerRef.current &&
!triggerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
setActiveSubmenu(null);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen]);
const mainItems = [
{ id: 'view', label: '视图', hasSubmenu: true },
{ id: 'sort', label: '排序', hasSubmenu: true },
{ id: 'filter', label: '筛选', hasSubmenu: true },
];
const submenus: Record<string, string[]> = {
view: ['列表视图', '网格视图', '画廊视图', '看板视图'],
sort: ['按名称', '按日期', '按大小', '按类型'],
filter: ['全部', '文档', '图片', '视频'],
};
return (
<div className="relative">
<button
ref={triggerRef}
type="button"
onClick={() => setIsOpen(!isOpen)}
className="rd-lg inline-flex items-center gap-2 border border-gray-3 bg-white px-4 py-2 transition-colors hover:bg-gray-1 dark:border-[#424242] dark:bg-[#262626] dark:hover:bg-[#404040]"
style={{ anchorName } as React.CSSProperties}
>
<Layout className="h-4 w-4" />
显示选项
<svg
className={`h-4 w-4 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
<div
ref={dropdownRef}
className={`rd-xl fixed z-50 min-w-45 border border-gray-3 bg-white py-1.5 shadow-xl transition-all duration-200 dark:border-[#424242] dark:bg-[#262626] ${
isOpen ? 'scale-100 opacity-100' : 'pointer-events-none scale-95 opacity-0'
}`}
style={
{
positionAnchor: anchorName,
top: 'anchor(bottom)',
left: 'anchor(left)',
translate: '0 8px',
positionTryFallbacks: 'flip-block',
} as React.CSSProperties
}
>
{mainItems.map((item) => (
<div key={item.id} className="relative">
<button
type="button"
className="flex w-full items-center justify-between gap-3 bg-[#fff] px-3 py-2 text-sm transition-colors dark:bg-#00000005"
style={{ anchorName: `--anchor-submenu-${item.id}` } as React.CSSProperties}
onMouseEnter={() => setActiveSubmenu(item.id)}
>
<span>{item.label}</span>
<svg className="h-4 w-4 opacity-40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="m9 18 6-6-6-6" />
</svg>
</button>
<div
className={`rd-xl fixed z-60 min-w-35 border border-gray-3 bg-white py-1.5 shadow-xl transition-all duration-150 dark:border-[#424242] dark:bg-[#262626] ${
activeSubmenu === item.id ? 'scale-100 opacity-100' : 'pointer-events-none scale-95 opacity-0'
}`}
style={
{
positionAnchor: `--anchor-submenu-${item.id}`,
top: 'anchor(top)',
left: 'anchor(right)',
translate: '4px 0',
positionTryFallbacks: 'flip-inline',
} as React.CSSProperties
}
onMouseLeave={() => setActiveSubmenu(null)}
>
{submenus[item.id].map((subItem, idx) => (
<button
key={idx}
type="button"
className="flex w-full items-center gap-3 bg-[#fff] px-3 py-2 text-sm transition-colors dark:bg-#00000005"
onClick={() => {
setIsOpen(false);
setActiveSubmenu(null);
}}
>
{subItem}
</button>
))}
</div>
</div>
))}
</div>
</div>
);
}
const Demo: React.FC = () => {
return (
<div className="p-6">
<section className="mb-12">
<div className="mb-6 font-semibold text-lg">基础下拉菜单</div>
<div className="rd-2xl border border-gray-3 bg-white p-8 dark:border-white-2 dark:bg-gray-6">
<div className="flex flex-wrap items-start gap-4">
<AnchorDropdown
trigger={
<>
<File className="h-4 w-4" />
文件操作
</>
}
items={fileMenuItems}
align="start"
/>
<AnchorDropdown
trigger={
<>
<User className="h-4 w-4" />
用户菜单
</>
}
items={userMenuItems}
align="start"
/>
</div>
</div>
</section>
<section className="mb-30">
<div className="mb-6 font-semibold text-lg">嵌套子菜单</div>
<div className="rd-2xl border border-gray-3 bg-white p-8 dark:border-white-2 dark:bg-gray-6">
<NestedDropdown />
<p className="mt-6 text-gray-6 text-sm dark:text-[#737373]">
悬停在菜单项上可以看到子菜单。子菜单使用 CSS Anchor 定位到父级菜单项的右侧。
</p>
</div>
</section>
</div>
);
};
export default Demo;
#核心特性
#1. 精确定位
下拉菜单使用 anchor() 函数精确定位到触发按钮的下方:
style={{
positionAnchor: anchorName,
top: 'anchor(bottom)',
left: 'anchor(left)',
translate: '0 8px',
positionTryFallbacks: 'flip-block',
}}#2. 自动翻转
使用 position-try-fallbacks: flip-block 实现自动翻转。当菜单在视口底部溢出时,会自动翻转到按钮上方显示。
#3. 嵌套子菜单
子菜单定位到父菜单项的右侧:
style={{
positionAnchor: `--anchor-submenu-${item.id}`,
top: 'anchor(top)',
left: 'anchor(right)',
translate: '4px 0',
positionTryFallbacks: 'flip-inline',
}}#4. 点击外部关闭
使用 useEffect 监听点击事件,点击菜单外部时自动关闭。
#实现要点
- 为每个下拉菜单生成唯一的锚点名称
- 使用
useRef管理 DOM 引用 - 通过
transition-all实现平滑的显示/隐藏动画 - 支持键盘快捷键显示(可选)
- 支持危险操作样式(如删除)
