shadcn ui 菜单组件迁移手册:Radix UI 到 Base UI 的 dropdown-menu、context-menu、menubar 与 navigation-menu 完整映射
本文基于仓库中的迁移知识库 menus.md(配套技能入口见 SKILL.md),系统讲解 shadcn 项目中菜单组件家族(dropdown-menu / context-menu / menubar / navigation-menu)从 Radix UI 迁移到 @base-ui/react(仓库当前锁定 1.6.0,见 apps/v4/package.json)时每个 part 的 props 改名、签名变化、默认值翻转与行为差异。读完后,你可以独立完成任意菜单组件的迁移,并准确处理 asChild → render、Content → Portal > Positioner > Popup 结构性重组、事件回调统一为 onOpenChange(open, eventDetails)、data 属性与 CSS 变量改写这四类核心问题。
一、迁移事实来源与 part 级总映射
这份映射表不是推测:其“ground truth”来自本仓库注册表中 61 组 Radix / Base 双风格组件对的机械 diff(universal-patterns.md 说明数据来自 apps/v4/registry/bases/{radix,base}/ui/ 的成对文件),Radix 侧类型/默认值提取自 radix-ui 官方页面内嵌类型载荷,Base UI 侧对应 1.6.0 文档(Base UI 1.x)。仓库中同时保留了两侧 wrapper 源码可供对照,例如 Radix 版 dropdown-menu.tsx 与 Base 版 dropdown-menu.tsx。
先看菜单家族中最重要的 part 级映射(从 wrapper 对照得出的 ground truth):
| Radix part | Base UI part |
|---|---|
Content |
Portal > Positioner > Popup(side / sideOffset / align / alignOffset 全部落到 Positioner 上) |
Label |
GroupLabel |
ItemIndicator |
CheckboxItemIndicator / RadioItemIndicator |
Sub |
SubmenuRoot |
SubTrigger |
SubmenuTrigger |
navigation-menu Viewport |
Positioner > Popup > Viewport |
navigation-menu Indicator |
Icon |
asChild |
render |
这一结构变化在 Base 版 dropdown-menu wrapper 中有直接体现:DropdownMenuContent 内部就是 MenuPrimitive.Portal 包 MenuPrimitive.Positioner 再包 MenuPrimitive.Popup,且定位 props 通过 Pick<MenuPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset"> 声明后逐个显式传给 Positioner(apps/v4/registry/bases/base/ui/dropdown-menu.tsx)。universal-patterns.md 特别警告了这里的“转发规则(FORWARD rule)”:如果你用 Pick 声明了定位 props,却没有在 wrapper 里解构并显式传给 Positioner,它们会经 ...props 落到 Popup 这个错误的 DOM 节点上,定位会静默失效,且没有任何 JSX 层面的类型错误能捕获。迁移自写 wrapper 时务必遵守“声明 → 解构 → 转发”三步。
二、跨组件通用规则(适用于所有 part)
以下规则对下文每个 part 表格中的同名 props 一律适用,是迁移时最高频的改动点:
| Radix 模式 | Base UI 对应 |
|---|---|
asChild(boolean,默认 false) |
render(ReactElement | ((props: HTMLProps, state) => ReactElement))。没有“合并到子元素”的布尔开关,直接传元素或函数。 |
Root 上的 dir("ltr" | "rtl") |
全部移除。Base UI 从 <DirectionProvider>(@base-ui-components/react/direction-provider)或 DOM dir 属性读取方向。 |
forceMount(boolean) |
Portal / indicator 部件上的 keepMounted(boolean,默认 false)。使用场景相同(动画/SEO),但显隐存在改为 CSS 驱动:用 data-starting-style / data-ending-style 替代 Radix 的 data-state + 强制挂载模式。 |
内容部件上的 onEscapeKeyDown / onPointerDownOutside / onFocusOutside / onInteractOutside |
不再作为独立 props 存在。统一改用 Root 上的 onOpenChange(open, eventDetails),按 eventDetails.reason 分支('escape-key'、'outside-press'、'focus-out' 等);调用 eventDetails.cancel() 可阻止关闭(替代原来的 event.preventDefault())。 |
Item 上的 onSelect((event: Event) => void;event.preventDefault() 可保持菜单打开) |
onClick((event: BaseUIEvent<React.MouseEvent<HTMLDivElement>>) => void)加 closeOnClick(boolean)控制点击后是否关闭菜单。 |
Item 上的 textValue(string,typeahead 文本) |
label(string)。 |
受控回调 (value) => void |
所有 Base UI 变更回调都带第二个 eventDetails 参数({ reason, event, cancel(), allowPropagation(), isCanceled, isPropagationAllowed, trigger })。 |
对应地,动画惯用法也要整体改写(详见 class-mapping.md):Radix 的 data-[state=open]:animate-in data-[state=closed]:animate-out(keyframes)应重写为 transition-[opacity,transform] data-starting-style:opacity-0 data-ending-style:opacity-0(transition + 起止样式),不要把 animate-in/out 工具类 1:1 照搬。
三、dropdown-menu:Radix DropdownMenu → Base UI Menu
Root → Menu.Root
| Radix prop | 类型 / 默认值 | Base UI 对应 | 迁移说明 |
|---|---|---|---|
defaultOpen |
boolean / – |
defaultOpen(boolean,默认 false) |
不变。 |
open |
boolean / – |
open(boolean) |
不变。 |
onOpenChange |
(open: boolean) => void / – |
onOpenChange |
签名变化:(open: boolean, eventDetails: Menu.Root.ChangeEventDetails) => void。eventDetails.reason 取值于 'trigger-hover' | 'trigger-focus' | 'trigger-press' | 'outside-press' | 'focus-out' | 'list-navigation' | 'escape-key' | 'item-press' | 'close-press' | 'sibling-open' | 'cancel-open' | 'imperative-action' | 'none';eventDetails.cancel() 可阻断状态变化。 |
modal |
boolean / true |
modal(boolean,默认 true) |
不变。 |
dir |
"ltr" | "rtl" / – |
– | 移除,改用 DirectionProvider。 |
Trigger → Menu.Trigger
| Radix prop | 类型 / 默认值 | Base UI 对应 | 迁移说明 |
|---|---|---|---|
asChild |
boolean / false |
render |
见通用规则。渲染非 button 元素时还要设置 nativeButton={false}。 |
Portal → Menu.Portal
| Radix prop | 类型 / 默认值 | Base UI 对应 | 迁移说明 |
|---|---|---|---|
forceMount |
boolean / – |
keepMounted(boolean,默认 false) |
改名;隐藏时保留 portal 在 DOM 中。 |
container |
HTMLElement / document.body |
container(HTMLElement | ShadowRoot | React.RefObject<HTMLElement | ShadowRoot | null> | null) |
类型放宽(接受 ref 和 ShadowRoot)。 |
Content → Menu.Portal > Menu.Positioner > Menu.Popup
这是 props 迁移量最大的 part,逐条列出:
| Radix prop | 类型 / 默认值 | Base UI 对应 | 迁移说明 |
|---|---|---|---|
asChild |
boolean / false |
Popup 上的 render |
见通用规则。 |
loop |
boolean / false |
Root 上的 loopFocus(boolean,默认 true) |
移动 + 改名,且默认值翻转:Base UI 默认循环焦点。 |
onCloseAutoFocus |
(event: Event) => void / – |
Popup 上的 finalFocus |
签名变化:boolean | React.RefObject<HTMLElement | null> | ((closeType: InteractionType) => boolean | void | HTMLElement | null),其中 InteractionType = 'mouse' | 'touch' | 'pen' | 'keyboard'。返回 false 等价于原来的 event.preventDefault();返回元素则重定向焦点。 |
onEscapeKeyDown |
(event: KeyboardEvent) => void / – |
– | 移除 → onOpenChange 且 reason === 'escape-key'。 |
onPointerDownOutside |
(event: PointerDownOutsideEvent) => void / – |
– | 移除 → reason === 'outside-press'。 |
onFocusOutside |
(event: FocusOutsideEvent) => void / – |
– | 移除 → reason === 'focus-out'。 |
onInteractOutside |
(event: PointerDownOutsideEvent | FocusOutsideEvent) => void / – |
– | 移除 → reason === 'outside-press' || 'focus-out'。 |
forceMount |
boolean / – |
Portal 上的 keepMounted |
移动;动画改用 data-starting-style/data-ending-style。 |
side |
"top" | "right" | "bottom" | "left" / "bottom" |
Positioner 上的 side(Side,默认 'bottom') |
移动。Base 增加逻辑值:Side = 'top' | 'bottom' | 'left' | 'right' | 'inline-end' | 'inline-start'。 |
sideOffset |
number / 0 |
Positioner 上的 sideOffset(number | OffsetFunction,默认 0) |
移动;还接受函数形式 (data: { side, align, anchor: {width,height}, positioner: {width,height} }) => number。 |
align |
"start" | "center" | "end" / "center" |
Positioner 上的 align(Align,默认 'center') |
移动,取值与默认值相同。 |
alignOffset |
number / 0 |
Positioner 上的 alignOffset(number | OffsetFunction,默认 0) |
移动;同样接受函数形式。 |
avoidCollisions |
boolean / true |
Positioner 上的 collisionAvoidance(CollisionAvoidance) |
签名变化:对象 { side?: 'flip' | 'shift' | 'none'; align?: 'flip' | 'shift' | 'none'; fallbackAxisSide?: 'start' | 'end' | 'none' }。avoidCollisions={false} 对应 collisionAvoidance={{ side: 'none', align: 'none', fallbackAxisSide: 'none' }}。 |
collisionBoundary |
Element | null | Array<Element | null> / [] |
Positioner 上的 collisionBoundary(Boundary,默认 'clipping-ancestors') |
默认值变化:Radix [] 表示视口/裁剪祖先;Base 默认 'clipping-ancestors' 与之等价。也接受元素或 rect。 |
collisionPadding |
number | Padding / 0 |
Positioner 上的 collisionPadding(Padding,默认 5) |
形状相同;默认值从 0 变为 5。 |
arrowPadding |
number / 0 |
Positioner 上的 arrowPadding(number,默认 5) |
相同;默认值从 0 变为 5。 |
sticky |
"partial" | "always" / "partial" |
–(见说明) | 概念不同。Radix sticky 控制 align 轴吸附,最接近的 Base 旋钮是 collisionAvoidance.align('shift' ≈ partial)。Base 自己的 sticky(boolean,默认 false)是让 popup 在锚点滚出视口后仍留在视口内,Radix 没有对应物。 |
hideWhenDetached |
boolean / false |
– | 作为行为 prop 被移除。Base 始终在 Positioner/Popup 上暴露 data-anchor-hidden;用 CSS 隐藏:[data-anchor-hidden] { visibility: hidden }。 |
Arrow → Menu.Arrow
| Radix prop | 类型 / 默认值 | Base UI 对应 | 迁移说明 |
|---|---|---|---|
asChild |
boolean / false |
render |
Base Arrow 渲染一个由你填入 SVG 的 <div>(Radix 直接渲染 svg)。放在 Popup 内部。 |
width |
number / 10 |
– | 移除;用 CSS 设置子 SVG/元素尺寸。 |
height |
number / 5 |
– | 移除;用 CSS 设置。 |
Item → Menu.Item
| Radix prop | 类型 / 默认值 | Base UI 对应 | 迁移说明 |
|---|---|---|---|
asChild |
boolean / false |
render |
链接场景用 Menu.LinkItem(渲染 <a>)而不是 render。 |
disabled |
boolean / – |
disabled(boolean,默认 false) |
不变。 |
onSelect |
(event: Event) => void / – |
onClick((event: BaseUIEvent<React.MouseEvent<HTMLDivElement>>) => void) |
改名 + 签名变化。原来在 onSelect 里 event.preventDefault() 保持菜单打开 → 改为 closeOnClick={false}(boolean,Item 上默认 true)。 |
textValue |
string / – |
label(string) |
改名。 |
Group / Label
| part | Radix prop | 类型 / 默认值 | Base UI 对应 | 迁移说明 |
|---|---|---|---|---|
Group → Menu.Group |
asChild |
boolean / false |
render |
其余不变。 |
Label → Menu.GroupLabel |
asChild |
boolean / false |
render |
part 改名。Base 的 GroupLabel 必须位于 Group 内部(它负责接线 aria-labelledby);Radix Label 可以游离使用。 |
CheckboxItem → Menu.CheckboxItem
| Radix prop | 类型 / 默认值 | Base UI 对应 | 迁移说明 |
|---|---|---|---|
asChild |
boolean / false |
render |
– |
checked |
boolean | 'indeterminate' / – |
checked(boolean) |
'indeterminate' 被移除。Base 新增非受控用 defaultChecked(boolean,默认 false)。 |
onCheckedChange |
(checked: boolean) => void / – |
onCheckedChange |
签名变化:(checked: boolean, eventDetails: Menu.CheckboxItem.ChangeEventDetails) => void。 |
disabled |
boolean / – |
disabled(boolean,默认 false) |
不变。 |
onSelect |
(event: Event) => void / – |
onClick + closeOnClick |
行为默认翻转:Radix 选中即关闭(除非 preventDefault);Base 的 CheckboxItem 上 closeOnClick 默认 false。要保留 Radix 行为需显式设置 closeOnClick。 |
textValue |
string / – |
label |
改名。 |
RadioGroup / RadioItem / ItemIndicator
| part | Radix prop | 类型 / 默认值 | Base UI 对应 | 迁移说明 |
|---|---|---|---|---|
RadioGroup → Menu.RadioGroup |
asChild |
boolean / false |
render |
– |
value |
string / – |
value(any) |
类型放宽为 any。Base 新增 defaultValue(any)与 disabled(boolean,默认 false)。 |
|
onValueChange |
(value: string) => void / – |
onValueChange |
签名变化:(value: any, eventDetails: Menu.RadioGroup.ChangeEventDetails) => void。 |
|
RadioItem → Menu.RadioItem |
asChild |
boolean / false |
render |
– |
value* |
string / – |
value*(any) |
不变(必填);类型放宽。 | |
disabled |
boolean / – |
disabled(boolean,默认 false) |
不变。 | |
onSelect |
(event: Event) => void / – |
onClick + closeOnClick |
RadioItem 上 closeOnClick 默认 false(Radix 默认关闭)。 |
|
textValue |
string / – |
label |
改名。 | |
ItemIndicator → Menu.CheckboxItemIndicator / Menu.RadioItemIndicator |
asChild |
boolean / false |
render |
part 拆分:按父 item 类型选用对应 indicator,渲染 <span>。 |
forceMount |
boolean / – |
keepMounted(boolean,默认 false) |
改名。 |
Separator / Sub / SubTrigger / SubContent
| part | Radix prop | 类型 / 默认值 | Base UI 对应 | 迁移说明 |
|---|---|---|---|---|
Separator → Menu.Separator |
asChild |
boolean / false |
render |
Base 新增 orientation('horizontal' | 'vertical',默认 'horizontal')。 |
Sub → Menu.SubmenuRoot |
defaultOpen |
boolean / – |
defaultOpen(boolean,默认 false) |
不变。 |
open |
boolean / – |
open(boolean) |
不变。 | |
onOpenChange |
(open: boolean) => void / – |
onOpenChange |
签名变化:(open: boolean, eventDetails: Menu.SubmenuRoot.ChangeEventDetails) => void(与 Root 相同的 reason 联合类型)。 |
|
SubTrigger → Menu.SubmenuTrigger |
asChild |
boolean / false |
render |
渲染 <div>;此处 nativeButton 默认 false。 |
disabled |
boolean / – |
disabled(boolean,默认 false) |
不变。 | |
textValue |
string / – |
label |
改名。Base 另增 openOnHover / delay(100)/ closeDelay(0)与 onClick。 |
SubContent → Menu.Portal > Menu.Positioner > Menu.Popup(位于 SubmenuRoot 内部):props 归宿与上面 Content 完全相同,但需注意两条 Radix 特有默认值——Radix SubContent 的 align 默认是 "start",而 Base Positioner 默认 'center';如果依赖了 Radix 默认值要显式写 align="start"(实践中子菜单 popup 锚定在触发项上,本仓库 wrapper 已在 SubContent 中把 align="start"、alignOffset=-3、side="right" 作为默认值处理)。另外 Radix SubContent 没有 side prop(方向隐含),Base Positioner 接受 side(RTL 感知的子菜单建议用 'inline-end')。所有 outside/escape 回调、forceMount、loop、collision 系列 props 的映射与 Content 一致。
Base UI 独有的值得了解的 props(Menu)
Root:highlightItemOnHover(默认true)、actionsRef({ unmount(), close() })、onOpenChangeComplete(open)(关闭动画结束后触发,替代 Radix“等动画结束”的绕行写法)、closeParentOnEsc(默认false)、disabled、orientation(默认'vertical')、detached-trigger 机制:handle(Menu.Handle,经Menu.createHandle()创建)、triggerId/defaultTriggerId、感知 payload 的children渲染函数。Trigger:openOnHover、delay(100)、closeDelay(0)、payload、handle、nativeButton(默认true)。Backdrop:新增 part,popup 下方的遮罩层。Positioner:anchor、positionMethod('absolute')、disableAnchorTracking、sticky(boolean,视口保持)。Popup:finalFocus。Viewport:新增 part,用于多触发器/detached 触发器之间内容切换的动画。LinkItem:新增 part,渲染<a>的菜单项(closeOnClick默认false)。- 所有 part:
className/style接受状态回调形式(state) => ...。
四、data 属性映射(dropdown / context / menubar 菜单通用)
样式代码里大量 data-[state=...] 选择器需要按 class-mapping.md 做机械改写,菜单家族对应关系如下:
| Radix | Base UI |
|---|---|
Trigger [data-state="open" | "closed"] |
data-popup-open(存在性属性)+ data-pressed |
Content [data-state="open" | "closed"] |
Positioner 和 Popup 上的 data-open / data-closed |
| – | data-starting-style / data-ending-style(CSS transition 钩子,替代对 data-state 做动画) |
Content [data-side="left" | "right" | "bottom" | "top"] |
Positioner/Popup/Arrow 上的 data-side('top' | 'bottom' | 'left' | 'right' | 'inline-end' | 'inline-start') |
Content [data-align="start" | "end" | "center"] |
Positioner/Popup/Arrow 上的 data-align(取值相同) |
Content/Item [data-orientation] |
菜单 part 上移除 |
Item [data-highlighted] |
data-highlighted(不变) |
Item [data-disabled] |
data-disabled(不变) |
Checkbox/RadioItem [data-state="checked" | "unchecked" | "indeterminate"] |
data-checked / data-unchecked 存在性属性;无 indeterminate |
ItemIndicator [data-state] |
拆分后的 indicator 上 data-checked / data-unchecked + data-starting-style / data-ending-style |
SubTrigger [data-state="open" | "closed"] |
SubmenuTrigger 上的 data-popup-open |
| – | Popup 的 data-instant('click' | 'dismiss' | 'group' | 'trigger-change')、Positioner 的 data-anchor-hidden |
仓库 Base 版 wrapper 已经体现了这些新钩子,例如 SubTrigger 的高亮类从 data-[state=open]: 改为 data-popup-open:bg-accent data-popup-open:text-accent-foreground(apps/v4/registry/bases/base/ui/dropdown-menu.tsx),Popup 的溢出控制类从 data-[state=closed]:overflow-hidden 改为 data-closed:overflow-hidden(同文件 L45)。
五、CSS 变量映射(dropdown-menu / context-menu / menubar)
| Radix(在 Content/SubContent 上) | Base UI(在 Positioner 上) |
|---|---|
--radix-<name>-content-transform-origin |
--transform-origin |
--radix-<name>-content-available-width |
--available-width |
--radix-<name>-content-available-height |
--available-height |
--radix-<name>-trigger-width |
--anchor-width |
--radix-<name>-trigger-height |
--anchor-height |
Base UI Menu 的 Viewport 另暴露 --popup-width / --popup-height(过渡期间的前一内容尺寸)。两侧 wrapper 的直接对比就是这条映射的活证据:Radix 版 Content 使用 w-(--radix-dropdown-menu-trigger-width)、max-h-(--radix-dropdown-menu-content-available-height)、origin-(--radix-dropdown-menu-content-transform-origin)(radix/ui/dropdown-menu.tsx L47),Base 版对应改为 w-(--anchor-width)、max-h-(--available-height)、origin-(--transform-origin)(base/ui/dropdown-menu.tsx L45)。
六、context-menu:Radix ContextMenu → Base UI ContextMenu
Base UI ContextMenu 与 Menu 共享同一套 part:Root, Trigger, Portal, Backdrop, Positioner, Popup, Arrow, Item, Group, GroupLabel, Separator, SubmenuRoot, SubmenuTrigger, RadioGroup, RadioItem, RadioItemIndicator, CheckboxItem, CheckboxItemIndicator, LinkItem。下文未单独列出的部分,映射与 dropdown-menu 章节完全一致(独立子路径为 @base-ui/react/context-menu,见 base/ui/context-menu.tsx)。
Root → ContextMenu.Root
| Radix prop | 类型 / 默认值 | Base UI 对应 | 迁移说明 |
|---|---|---|---|
dir |
"ltr" | "rtl" / – |
– | 移除;用 DirectionProvider。 |
open |
boolean / – |
open(boolean) |
不变。Base 另加 defaultOpen(默认 false),这是 Radix ContextMenu 原本没有的。 |
onOpenChange |
(open: boolean) => void / – |
onOpenChange |
签名变化:(open: boolean, eventDetails: ContextMenu.Root.ChangeEventDetails) => void(与 Menu 相同的 reason 联合)。 |
modal |
boolean / true |
– | 移除。 Base UI ContextMenu.Root 没有 modal prop(行为固定)。若你依赖 modal={false},没有直接等价物。 |
Trigger → ContextMenu.Trigger
| Radix prop | 类型 / 默认值 | Base UI 对应 | 迁移说明 |
|---|---|---|---|
asChild |
boolean / false |
render |
Base Trigger 渲染 <div>(同时处理触摸长按)。 |
disabled |
boolean / false |
– | 移除。 ContextMenu.Trigger 只有 className/style/render。变通:在 Trigger 外条件性渲染内容,或在子元素上拦截 onContextMenu 并 preventDefault + stopPropagation。 |
Portal / Content
Portal 与 Menu.Portal 完全相同(forceMount → keepMounted,container 放宽)。
Content → ContextMenu.Portal > Positioner > Popup,与 dropdown-menu Content 相同的归宿:asChild、loop(→ Root loopFocus)、onCloseAutoFocus(→ Popup finalFocus)、onEscapeKeyDown/onPointerDownOutside/onFocusOutside/onInteractOutside(→ Root onOpenChange reason)、forceMount(→ Portal keepMounted)、avoidCollisions(→ collisionAvoidance)、collisionBoundary(默认 [] → 'clipping-ancestors')、collisionPadding(0 → 5)、sticky(移除,见 Menu 说明)、hideWhenDetached(→ data-anchor-hidden 上的 CSS)。
Radix 特有差异:
| Radix prop | 类型 / 默认值 | Base UI 对应 | 迁移说明 |
|---|---|---|---|
alignOffset |
number / 0 |
Positioner 上的 alignOffset(number | OffsetFunction,默认 0) |
Radix ContextMenu.Content 没有 side/sideOffset/align props(锚定在指针上)。Base Positioner 仍接受 side/align/sideOffset,但默认锚定指针位置;通常保持不设置。 |
(Content 上无 arrowPadding) |
– | Positioner 上的 arrowPadding(5) |
若加 Arrow,Base 提供该 prop。 |
Arrow / Item / Group / Label / CheckboxItem / RadioGroup / RadioItem / ItemIndicator / Separator / Sub / SubTrigger / SubContent 的归宿与 dropdown-menu 章节完全一致(Label → GroupLabel,ItemIndicator → CheckboxItemIndicator/RadioItemIndicator,Sub → SubmenuRoot,SubTrigger → SubmenuTrigger,SubContent → Portal > Positioner > Popup)。Base 中 Menubar/ContextMenu 的子菜单就是相同 props 的同一组组件(onOpenChange eventDetails、label、onClick/closeOnClick、keepMounted)。
Base UI 独有能力、data 属性、CSS 变量与 Menu 章节相同;ContextMenu.Root 额外支持 handle(MenuHandle<unknown>)、triggerId/defaultTriggerId、actionsRef、onOpenChangeComplete、highlightItemOnHover、closeParentOnEsc、disabled、orientation。Trigger data 属性:data-popup-open、data-pressed(替代 Radix Trigger 的 [data-state])。CSS 变量:--radix-context-menu-* → Positioner 上的 --transform-origin / --available-* / --anchor-*。
七、menubar:Radix Menubar → Base UI Menubar + Menu
Base UI 的 menubar 模块只导出一个 <Menubar> 容器,其中每个菜单都由 Menu.* part 构建(Menu.Root、Menu.Trigger、Menu.Portal、Menu.Positioner、Menu.Popup、items、submenus…)。因此 Radix Menubar.Menu/Trigger/Portal/Content/... 的 part 全部落到 dropdown-menu 章节的 Menu 组件族上。仓库 Base 版 menubar.tsx 正是这种“委托”结构的直接实现:MenubarMenu 直接复用 DropdownMenu,MenubarGroup/MenubarPortal 等也全部包装 DropdownMenu 族组件(apps/v4/registry/bases/base/ui/menubar.tsx)。
Root → Menubar
| Radix prop | 类型 / 默认值 | Base UI 对应 | 迁移说明 |
|---|---|---|---|
asChild |
boolean / false |
render |
同模式。 |
defaultValue |
string / – |
– | 移除。 Base Menubar 没有受控/非受控的“当前活动菜单”值。要预打开某个菜单,用该 Menu.Root 的 defaultOpen。 |
value |
string / – |
– | 移除。 改为控制各个 Menu.Root 的 open prop。 |
onValueChange |
(value: string) => void / – |
– | 移除。 通过每个 Menu.Root 的 onOpenChange 监听。 |
dir |
"ltr" | "rtl" / – |
– | 移除;用 DirectionProvider。 |
loop |
boolean / false |
loopFocus(boolean,默认 true) |
改名;默认值翻转为 true。 |
Base UI Menubar 独有:modal(boolean,默认 true)、disabled(boolean,默认 false)、orientation('horizontal' \| 'vertical',默认 'horizontal')。
Menu / Trigger
| part | Radix prop | 类型 / 默认值 | Base UI 对应 | 迁移说明 |
|---|---|---|---|---|
Menu → Menu.Root |
asChild |
boolean / false |
– | Menu.Root 不渲染元素,直接删掉。 |
value |
string / – |
– | 随 Menubar value 体系一起移除(见 Root)。 | |
Trigger → Menu.Trigger |
asChild |
boolean / false |
render(非 button 时加 nativeButton={false}) |
Radix Menubar.Trigger 的 [data-state]/[data-highlighted]/[data-disabled] → data-popup-open/data-pressed(Base trigger 没有 highlighted 状态)。 |
注意:Menubar 内部的 Menu.Root 接受全部 Menu.Root props(open、defaultOpen、onOpenChange(open, eventDetails)、modal、loopFocus、orientation、disabled…),并且 menubar 各菜单之间的悬停切换是内置的。
Portal / Content / Arrow / Item / Group / Label / CheckboxItem / RadioGroup / RadioItem / ItemIndicator / Separator / Sub / SubTrigger / SubContent
与 dropdown-menu 章节完全一致(它们本来就是同一组 Base UI Menu 组件):
Portal.forceMount→keepMounted;container放宽。Content(loop、onCloseAutoFocus、outside/escape 回调、forceMount、side/sideOffset/align/alignOffset、avoidCollisions、collisionBoundary、collisionPadding、arrowPadding、sticky、hideWhenDetached)→Menu.Portal > Menu.Positioner > Menu.Popup,归宿与 dropdown-menu Content 的表格逐条相同。SubContent的align默认"start"注意事项同 dropdown-menu。- Items:
onSelect→onClick+closeOnClick,textValue→label。 - Radix Menubar CheckboxItem/RadioItem 的
[data-state="checked" \| "unchecked"]→data-checked/data-unchecked。
data 属性 / CSS 变量
- Menubar 容器:Radix Root 没有任何 data 属性;Base
Menubar暴露data-orientation('horizontal' \| 'vertical')、data-has-submenu-open、data-modal。 - 菜单 part 的属性与
--radix-menubar-*CSS 变量按 dropdown-menu 两张表映射(Positioner 上的--transform-origin、--available-width/height、--anchor-width/height)。
八、navigation-menu:Radix NavigationMenu → Base UI NavigationMenu
Base UI 的 part 集合为:Root, List, Item, Trigger, Icon, Content, Portal, Backdrop, Positioner, Popup, Arrow, Viewport, Link。Popup 定位采用真正的锚定定位模型(与 Menu 相同):共享 popup 渲染为 Portal > Positioner > Popup > Viewport,每个 Item 的 Content 在激活时被移入 Viewport。Radix“Viewport 渲染在列表下方”的模型被这套锚定 Positioner 模型取代(wrapper 也随之移除了 viewport 布尔 prop)。Base 版 navigation-menu.tsx 中的 NavigationMenuPositioner 完整展示了 Portal > Positioner > Popup > Viewport 四层结构与 --positioner-width/height、--popup-width/height、data-instant 的用法(apps/v4/registry/bases/base/ui/navigation-menu.tsx)。
Root → NavigationMenu.Root
| Radix prop | 类型 / 默认值 | Base UI 对应 | 迁移说明 |
|---|---|---|---|
defaultValue |
string / – |
defaultValue(Value | null,默认 null) |
类型放宽(Value = any);null = 关闭。 |
value |
string / – |
value(Value | null,默认 null) |
相同;非 nullish = 打开。 |
onValueChange |
(value: string) => void / – |
onValueChange |
签名变化:(value: Value | null, eventDetails: NavigationMenu.Root.ChangeEventDetails) => void;reason 取值:'trigger-press' | 'trigger-hover' | 'outside-press' | 'list-navigation' | 'focus-out' | 'escape-key' | 'link-press' | 'none'。 |
delayDuration |
number / 200 |
delay(number,默认 50) |
改名;默认值从 200 变为 50。 |
skipDelayDuration |
number / 300 |
– | 移除。 没有 skip-delay 窗口;Base 改用 closeDelay(number,默认 50)。 |
dir |
"ltr" | "rtl" / – |
– | 移除;用 DirectionProvider。 |
orientation |
"horizontal" | "vertical" / "horizontal" |
orientation(取值/默认值相同) |
不变。 |
Base UI Root 独有:closeDelay(50)、actionsRef({ unmount() })、onOpenChangeComplete(open)。Root 渲染 <nav> 元素(Radix Root 也渲染 <nav>;嵌套时 Base 渲染 <div>)。
Sub → 嵌套 NavigationMenu.Root
| Radix prop | 类型 / 默认值 | Base UI 对应 | 迁移说明 |
|---|---|---|---|
defaultValue / value / onValueChange / orientation |
同 Root | 嵌套 Root 上的同名 props |
part 移除:在 Content 里嵌套一个完整的 NavigationMenu.Root(带自己的 List/Portal/Positioner/Popup);嵌套时渲染 <div>。与 Radix Sub 不同,嵌套的 Base 菜单默认是关闭的(null),不要求始终有一个活动项。 |
List / Item / Trigger
| part | Radix prop | 类型 / 默认值 | Base UI 对应 | 迁移说明 |
|---|---|---|---|---|
List → NavigationMenu.List |
asChild |
boolean / false |
render |
Base List 渲染 <ul>(Radix 同为 <ul>)。Radix 的 [data-orientation] 属性移除。 |
Item → NavigationMenu.Item |
asChild |
boolean / false |
render |
Base Item 渲染 <li>。 |
value |
string / – |
value(any) |
不变;省略时自动生成。 | |
Trigger → NavigationMenu.Trigger |
asChild |
boolean / false |
render(另有 nativeButton,默认 true) |
[data-state="open" | "closed"] → data-popup-open;[data-disabled] 移除(也没有 disabled prop——在 item 层级自行拦截)。 |
Content → NavigationMenu.Content
| Radix prop | 类型 / 默认值 | Base UI 对应 | 迁移说明 |
|---|---|---|---|
asChild |
boolean / false |
render |
– |
onEscapeKeyDown |
(event: KeyboardEvent) => void / – |
– | 移除 → Root onValueChange 且 reason === 'escape-key' + eventDetails.cancel()。 |
onPointerDownOutside |
(event: PointerDownOutsideEvent) => void / – |
– | 移除 → reason === 'outside-press'。 |
onFocusOutside |
(event: FocusOutsideEvent) => void / – |
– | 移除 → reason === 'focus-out'。 |
onInteractOutside |
(event: PointerDownOutsideEvent | FocusOutsideEvent) => void / – |
– | 移除 → 'outside-press' | 'focus-out'。 |
forceMount |
boolean / – |
keepMounted(boolean,默认 false) |
改名,仍留在 Content 上(关闭时保留内容在 DOM 中,例如用于 SEO/SSR)。 |
Link / Indicator / Viewport
| part | Radix prop | 类型 / 默认值 | Base UI 对应 | 迁移说明 |
|---|---|---|---|---|
Link → NavigationMenu.Link |
asChild |
boolean / false |
render |
框架链接:render={<NextLink href=... />}。 |
active |
boolean / false |
active(boolean,默认 false) |
不变(设置 aria-current + data-active)。 |
|
onSelect |
(event: Event) => void / – |
– | 移除。改用 onClick(普通 DOM prop)与 closeOnClick(boolean,默认 false)——注意 Radix 默认点链接即关闭菜单,Base 不关闭;要行为对齐需显式设置 closeOnClick。 |
|
Indicator → NavigationMenu.Icon |
asChild |
boolean / false |
render |
角色不同:Radix Indicator 在列表下方追踪活动触发器;Base Icon 是 Trigger 内的箭头(其菜单打开时带 data-popup-open)。要做 popup 锚定的指针,Base 的 Arrow(在 Popup 内,带 data-side/data-align/data-uncentered)是最接近的视觉对应物。Base 没有沿列表追踪活动触发器的 part。 |
forceMount |
boolean / – |
– | 移除(Icon 始终渲染)。 | |
[data-state="visible" | "hidden"]、[data-orientation] |
– | – | 移除;Icon 只暴露 data-popup-open。 |
|
Viewport → NavigationMenu.Portal > Positioner > Popup > Viewport |
asChild |
boolean / false |
每个新 part 上的 render |
一个 Radix part 变四个:Portal(props:container、keepMounted)、Positioner(与 Menu.Positioner 完全相同的锚定定位 props 集合:side/sideOffset/align/alignOffset(number | OffsetFunction)、anchor、collisionAvoidance、collisionBoundary('clipping-ancestors')、collisionPadding(5)、arrowPadding(5)、sticky(boolean)、positionMethod、disableAnchorTracking)、Popup(渲染 <nav>)、Viewport(裁剪/动画活动的 Content)。 |
forceMount |
boolean / – |
Portal 上的 keepMounted |
改名 + 移动。 |
Base UI 独有(NavigationMenu):Root 的 closeDelay、actionsRef、onOpenChangeComplete;新 part Backdrop、Arrow、Positioner(真正的碰撞感知定位——Radix nav-menu 没有定位能力)、Icon;Content.keepMounted 供爬虫可见的 SSR 内容;Link.closeOnClick;所有 part 接受 className/style 状态回调形式与 render。
data 属性映射(navigation-menu)
| Radix | Base UI |
|---|---|
Root/Sub/List/Item [data-orientation] |
移除。 |
Trigger [data-state="open" | "closed"] |
Trigger(以及 Icon)上的 data-popup-open。 |
Trigger [data-disabled] |
移除。 |
Content [data-state="open" | "closed"] |
Content 上的 data-open / data-closed(Positioner/Popup/Backdrop 上也有)。 |
Content [data-motion="to-start" | "to-end" | "from-start" | "from-end"] |
Content 上的 data-activation-direction('left' | 'right' | 'up' | 'down')——新激活触发器相对上一个的方向,用于进出场动画。 |
Link [data-active] |
data-active(不变)。 |
Indicator [data-state="visible" | "hidden"] |
无对应物(见 Indicator 行)。Arrow 暴露 data-open/data-closed/data-uncentered/data-side/data-align。 |
Viewport [data-state]、[data-orientation] |
Popup/Positioner 上的 data-open/data-closed + data-starting-style/data-ending-style;Viewport 本身不暴露。 |
| – | Positioner:data-anchor-hidden、data-instant;Popup:data-side、data-align。 |
仓库 Base 版 wrapper 已用 data-activation-direction + data-starting-style/data-ending-style 重写了切换动画(例如 data-starting-style:data-activation-direction=left:translate-x-[-50%] data-ending-style:opacity-0,见 apps/v4/registry/bases/base/ui/navigation-menu.tsx),替换了 Radix 的 data-motion 方案。
CSS 变量映射(navigation-menu)
| Radix | Base UI |
|---|---|
--radix-navigation-menu-viewport-width(在 Viewport 上) |
--popup-width(number,在 Popup 上)——popup 的固定宽度;动画可用 width: var(--popup-width)。 |
--radix-navigation-menu-viewport-height(在 Viewport 上) |
--popup-height(number,在 Popup 上)。 |
| – | Positioner 另暴露 --anchor-width、--anchor-height、--available-width、--available-height、--positioner-width、--positioner-height、--transform-origin。 |
九、缺口与注意事项(Gaps / caveats)
以下是原始文档明确记录的边界,迁移时必须如实报告而不是静默“修好”:
- Radix prop 描述在官方站点以 JS popover 呈现;本文中的类型/默认值均提取自页面内嵌类型载荷(
(open: boolean) => void、(checked: boolean) => void、(value: string) => void、(event: KeyboardEvent) => void、(event: PointerDownOutsideEvent) => void、(event: FocusOutsideEvent) => void、(event: PointerDownOutsideEvent | FocusOutsideEvent) => void、onSelect/onCloseAutoFocus的(event: Event) => void、Boundary = Element | null | Array<Element | null>、sticky: "partial" | "always"、dir: "ltr" | "rtl"),并对照抓取到的 HTML 验证过。 - 三处“硬移除”没有一行代码的等价变通:Base UI ContextMenu.Root 确实没有
modal;Menubar 没有 value/onValueChange 体系;ContextMenu.Trigger 没有disabled。 - Base UI 文档取自
.md端点,对应 Base UI 1.x;本仓库当前锁定@base-ui/react1.6.0(apps/v4/package.json),阅读 props 细节时应以该版本为准。
十、实操建议:结合仓库技能流程迁移
本仓库把这份映射放在一个完整的 Agent 迁移技能里(skills/migrate-radix-to-base/SKILL.md)。迁移菜单组件时的可执行要点:
- 先分类 wrapper,再选路径:把用户文件与对应风格的 stock 版本 diff 一下。若是 shadcn 项目且存在成对 registry 版本,优先走“golden pair”(CLI 拉取 base 变体);手写 Radix 组合或未知风格才用本表的转换引擎逐条改写。
- 导入改写:
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"→import { Menu as MenuPrimitive } from "@base-ui/react/menu";类型写法从React.ComponentProps<typeof XPrimitive.Part>改为XPrimitive.Part.Props(见 universal-patterns.md 的导入规则)。 - 结构性重组:
Content一律拆为Portal > Positioner > Popup;定位 props 记得“声明 → 解构 → 转发”三步(见第一节 FORWARD rule 警告);Overlay场景不涉及菜单,但菜单的Backdrop是新 part 可按需使用。 - 类字符串层机械改写:按 class-mapping.md 全量替换
data-[state=open]→data-open、data-[state=closed]→data-closed、data-[state=checked]→data-checked、submenu trigger 的data-[state=open]:→data-popup-open:,并把 animate-in/out 动画重写为data-starting-style:/data-ending-style:transition。 - 行为差异要标记而不是修补:Base 的 Menu.Item
closeOnClick语义、CheckboxItem/RadioItem 的closeOnClick默认false、navigation-menudelay默认 50ms(Radix 200ms)等,编译通过但行为不同的差异应在迁移报告中单列“Behavior changes”一节,供人工 QA 验证(菜单的键盘导航、typeahead、关闭后焦点回归等)。
参考文件汇总:映射主表 menus.md、通用模式与覆盖矩阵 universal-patterns.md、类改写表 class-mapping.md、调用方 props 改写 consumer-props.md、目标形态 wrapper-shapes.md;两侧对照实现 radix/ui/dropdown-menu.tsx、base/ui/dropdown-menu.tsx、base/ui/context-menu.tsx、base/ui/menubar.tsx、base/ui/navigation-menu.tsx。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00