shadcn ui 仓库 Radix UI 到 Base UI 迁移模式全解:覆盖矩阵、Positioner 定位模型与 Slot → useRender 实战
本文以 shadcn ui/ui 仓库中迁移知识库文件 universal-patterns.md 为主体,系统梳理 Radix UI 到 Base UI(@base-ui/react)的组件级迁移映射:从 27 个 Radix 原语的覆盖矩阵、导入/asChild/定位模型/数据属性/CSS 变量五类通用改写模式,到逐组件迁移要点与两个典型陷阱,并结合仓库内 apps/v4/registry/bases/{radix,base}/ui/ 下 60 余对真实组件源码差异做佐证。读完本文,你可以独立判断任意一个 shadcn 组件从 Radix 底座的改写路径,理解 Base UI Portal > Positioner > Popup 新定位模型的落地方式,并掌握 useRender + mergeProps 的完整写法。
知识库的来源与事实边界
universal-patterns.md 是仓库为「迁移 Agent」准备的 primitives/ 技能知识库,其结论基于三路证据(文档开头即声明):
- 机械 diff:
apps/v4/registry/bases/{radix,base}/ui/下 61 对组件的逐行对比(第一手事实来源,由仓库团队制作); radix-ui@1.4.3的包导出;@base-ui/react@1.6.0的文档索引。
文档构建日期为 2026-07-02。该文件与 SKILL.md(定义迁移 Agent 的整体工作流)、class-mapping.md(data 属性与 CSS 变量改写表)、wrapper-shapes.md(目标组件形状)等文件共同组成 skills/migrate-radix-to-base/ 技能目录。理解这一点很关键:覆盖矩阵和 Part 重命名表以仓库内的 golden pair diff 为准,而逐原语的回调签名、受控 prop 细节仍需对照官方文档验证——文档自己也列出了待验证清单(见后文「文档验证 TODO」一节)。
覆盖矩阵:27 个 Radix 原语的迁移分类
这是知识库的核心骨架:对 Radix 的全部导出逐一分类。迁移复杂度从「直接 1:1」到「彻底重构」不等:
| Radix 原语 | Base UI 目标 | 分类 |
|---|---|---|
| Accordion | Accordion | 直接迁移(Content→Panel) |
| AlertDialog | Alert Dialog | 重构(Overlay→Backdrop,Content→Popup,Cancel→Close,Action 无对应) |
| AspectRatio | 无 | 缺失:普通 div + CSS aspect-ratio(--ratio 变量) |
| Avatar | Avatar | 直接 |
| Checkbox | Checkbox | 直接(最干净的 1:1) |
| Collapsible | Collapsible | 直接(Content→Panel) |
| ContextMenu | Context Menu | 重构(菜单映射) |
| Dialog | Dialog | 重构(Overlay→Backdrop,Content→Popup) |
| DropdownMenu | Menu | 改名 + 重构(标准菜单映射) |
| Form | Form + Field + Fieldset | 重构(拆分为三个) |
| HoverCard | Preview Card | 改名 + Positioner 模型 |
| Label | 无 | 缺失:原生 <label>(表单中用 Field.Label) |
| Menubar | Menubar + Menu | 重构(仅 menubar root;菜单委托给 Menu) |
| NavigationMenu | Navigation Menu | 重度重构(Viewport → Positioner/Popup/Viewport,Indicator→Icon) |
| Popover | Popover | Positioner 模型(Anchor 被移除;需对照文档核实) |
| Progress | Progress | 重构(新增 Track/Label/Value 部分,不再手动 transform) |
| RadioGroup | Radio Group + Radio | 重构(Item → Radio.Root,两个子路径导入) |
| ScrollArea | Scroll Area | 直接(Scrollbar/Thumb 改名) |
| Select | Select | 重构(Viewport→List,ScrollButtons→ScrollArrows,alignItemWithTrigger) |
| Separator | Separator | 直接(可调用组件;decorative 被移除) |
| Slider | Slider | 重构(Range→Indicator,新增 Control,thumbAlignment) |
| Switch | Switch | 直接(1:1) |
| Tabs | Tabs | 直接(Trigger→Tab,Content→Panel) |
| Toast | Toast | 重构(仓库 golden pair 未覆盖;规格来自文档;shadcn 用户多用 sonner) |
| Toggle | Toggle | 直接(可调用) |
| ToggleGroup | Toggle Group + Toggle | 直接(items 复用 Toggle 原语) |
| Toolbar | Toolbar | 大致直接(不在仓库 pair 中;规格来自文档) |
| Tooltip | Tooltip | Positioner 模型(delayDuration→Provider 的 delay) |
| unstable_OneTimePasswordField | OTP Field | 来自文档(仓库 registry 用的是 input-otp) |
| unstable_PasswordToggleField | 无 | 缺失:Input + 自定义 toggle |
工具类(utilities)的映射:
| Radix 工具 | Base UI 对应 |
|---|---|
| Slot / asChild | render prop;手动 Slot 惯用法用 useRender + mergeProps |
| Portal | 无独立 Portal;各组件自带 Portal 部分 |
| VisuallyHidden | 无;用 sr-only class |
| AccessibleIcon | 无;aria-label + sr-only 文本 |
| Direction | Direction Provider |
Base UI 独有的新能力(不是迁移目标):Autocomplete、Combobox、Input、Number Field、Checkbox Group、Meter、Filter、CSP Provider。
两类「永不触碰」对象:
- 第三方库(两侧都不是 Radix):cmdk(command)、vaul(drawer)、sonner、input-otp、react-day-picker(calendar)、recharts(chart)——迁移时保持原样并在报告中说明;
- 文档的一次修正(dry-run 发现):Base UI 也提供
Button原语(@base-ui/react/button),原生支持render。因此使用 Slot/asChild 惯用法的button.tsx应直接迁移到<ButtonPrimitive>,而不是手写useRender包装。useRender + mergeProps只留给非按钮的多态组件(breadcrumb link、marker 等)。
通用迁移模式(适用于所有组件)
以下五类模式覆盖绝大多数改写场景。
1. 导入改写:两种 Radix 导入形式
Radix 在代码中存在两种导入形态,二者映射到同一个 Base UI 子路径:
- 统一包(当前 shadcn 主流):
import { X as XPrimitive } from "radix-ui"
// ->
import { X as XPrimitive } from "@base-ui/react/<kebab-name>"
- 独立包(legacy/2024 年代写法):
import * as XPrimitive from "@radix-ui/react-<name>"
// ->
import { X as XPrimitive } from "@base-ui/react/<kebab-name>"
注意:命名空间 * as 导入要改为命名导入,并从 package.json 中移除对应的 @radix-ui/react-* 包。无论哪种形式,每个组件只对应一个子路径。
类型写法同步变化:
// Radix
React.ComponentProps<typeof XPrimitive.Part>
// Base UI
XPrimitive.Part.Props
定位类 prop 的 Pick 写法:Pick<XPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">。
单部分原语可直接调用:Radix 的 XPrimitive.Root 在 Base UI 中直接是 XPrimitive(separator、toggle、toggle-group root、radio-group root、menubar root 均如此)。
2. asChild → render
- 直接包装场景:
<Primitive.Close asChild><Button/></Primitive.Close>改为<Primitive.Close render={<Button/>}>...</Primitive.Close>; - 手动 Slot 惯用法(
const Comp = asChild ? Slot.Root : "a")改为useRender+mergeProps(来自@base-ui/react/use-render/@base-ui/react/merge-props),prop 类型用useRender.ComponentProps<"a">。完整工作示例见后文专节。
仓库内 base/ui/dialog.tsx 展示了前者:DialogPrimitive.Close 通过 render={<Button variant="ghost" .../>} 直接复用 Button;对应的 radix/ui/dialog.tsx 则是 asChild 写法。
3. Portal 与定位模型(最大的结构性变化)
这是 Radix → Base UI 迁移中最核心的结构差异:
- Radix:
Portal > Content,定位 prop(side、align 等)直接挂在 Content 上; - Base UI:
Portal > Positioner > Popup。side、sideOffset、align、alignOffset(以及 select 的alignItemWithTrigger)全部移到 Positioner 上;Popup 才是承载样式的盒子。Positioner 按惯例加isolate z-50; Overlay→Backdrop(dialogs、sheets、drawers);- 居中模态(dialog/alert-dialog)不使用 Positioner,只有 Popup。
base/ui/dialog.tsx 的 golden pair 印证了这一点:DialogContent 直接渲染 DialogPrimitive.Popup,用 fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 手动居中,中间没有 Positioner;而 radix/ui/dialog.tsx 的 DialogContent 则直接就是 DialogPrimitive.Content(定位能力内置)。同文件中 Overlay 部分也换成了 Backdrop(base 版 L26-L37)。
4. 数据属性 / class 钩子
- 状态属性:
data-[state=open]→data-open;data-[state=closed]→data-closed; - 进出场动画机制变了:Radix 的 keyframes 类
data-[state=open]:animate-in/data-[state=closed]:animate-out改为基于 transition 的data-starting-style:*/data-ending-style:*; - Base UI 新增钩子:
data-popup-open(打开的子菜单/触发器标记); - 部分触发器需要
disabled:*与aria-disabled:*并存(accordion、tabs)。
base/ui/accordion.tsx 的 trigger 类名即可见 aria-disabled:pointer-events-none aria-disabled:opacity-50,而 radix/ui/accordion.tsx 是 disabled:pointer-events-none disabled:opacity-50。
5. CSS 自定义属性改名
| Radix 变量 | Base UI 变量 |
|---|---|
--radix-<comp>-content-transform-origin |
--transform-origin |
--radix-<comp>-content-available-height |
--available-height |
--radix-<comp>-trigger-width |
--anchor-width |
--radix-accordion-content-height |
--accordion-panel-height |
nav-menu --radix-navigation-menu-viewport-height/width |
--positioner-height/width、--popup-height/width、--available-width |
仓库源码中可直接对照:radix/ui/accordion.tsx 使用 h-(--radix-accordion-content-height),base/ui/accordion.tsx 改为 h-(--accordion-panel-height) data-ending-style:h-0 data-starting-style:h-0——变量改名与 starting/ending-style 动画改写同时出现在这一行,是本模式的典型样本。
Props 层面的变化
- Tooltip Provider:
delayDuration→delay; - Select:
position="popper" | "item-aligned"→ Positioner 上的布尔 propalignItemWithTrigger; - Slider:新增
thumbAlignment(取"edge");Range→Indicator+ 新增Control; - Navigation Menu:
viewport布尔 prop 被移除;align转发给 Positioner; value/defaultValue/onOpenChange在包装层签名保持不变(但按文档提示,撰写规格时应逐原语对照官方文档核对回调签名,因为 golden pair 的 wrapper 并未覆盖所有回调)。
Part 重命名速查表
| Radix part | Base UI part |
|---|---|
*.Root(单部分组件) |
可调用 *Primitive |
Overlay |
Backdrop |
Content(overlay 类组件) |
Popup(位于 Positioner 内) |
Content(accordion/collapsible/tabs) |
Panel |
tabs Trigger |
Tab |
menu Label |
GroupLabel |
menu ItemIndicator |
CheckboxItemIndicator / RadioItemIndicator |
Sub / SubTrigger |
SubmenuRoot / SubmenuTrigger |
slider Range |
Indicator(+ 新增 Control) |
select Viewport |
List |
select ScrollUp/DownButton |
ScrollUp/DownArrow |
scroll-area ScrollAreaScrollbar / ScrollAreaThumb |
Scrollbar / Thumb |
nav-menu Indicator |
Icon |
nav-menu Viewport |
Positioner > Popup > Viewport |
hover-card HoverCard* |
PreviewCard* |
radio-group Item / Indicator |
Radio.Root / Radio.Indicator |
popover Anchor |
移除(需对照文档核实) |
alert-dialog Cancel / Action |
Close / 移除(普通 Button) |
separator decorative prop |
移除 |
| Label 原语 | 原生 <label> |
逐组件迁移要点
accordion
Root/Item/Header/Trigger 不变;Content → Panel。Trigger 的 disabled:* → aria-disabled:*;高度变量改为 --accordion-panel-height;补上 data-starting-style:h-0 data-ending-style:h-0。仓库中这对文件的差异与上述描述完全一致(见上一节引用)。
dialog / alert-dialog / sheet
Overlay → Backdrop,Content → Popup,Close 保留(asChild → render)。alert-dialog 中 Cancel → Close;Action 没有对应原语,用普通 Button。sheet 的滑入动画从 animate-in/out 重写为按 data-[side=...] 显式 translate 的 data-starting-style / data-ending-style。居中模态(dialog/alert-dialog)不带 Positioner。
drawer(vaul → Base UI)——仅当用户明确要求时
硬规则:Vaul 不是 Radix。在 radix → base-ui 迁移中,drawer.tsx 保持原样并在报告中说明;只有用户明确要求把 drawer 从 vaul 移走时才执行本映射(见 SKILL.md 的 Hard rules)。若执行:Root 新增 modal、snapPoints、swipeDirection(默认 "down")、showSwipeHandle;原单一 Content 拆为 Viewport > Popup > Content;data-[vaul-drawer-direction=...] 改为 data-[swipe-direction=...] / data-[swipe-axis=...] + --drawer-* 变量;新增 SwipeHandle 部分和包装层 context provider。注意这本质是 vaul 迁移,不是 Radix 迁移。
popover / tooltip / hover-card
三者都是 Portal > Positioner > Popup。Popover:Anchor 被移除,Title 成为真正的原语部分。Tooltip:Provider 的 delayDuration → delay;Content 新增 side/align/alignOffset;默认 sideOffset 从 0 变为 4;Arrow 需要显式的按方向定位类。HoverCard:原语改名为 PreviewCard(公共包装名保持 HoverCard* 不变)。
menus(dropdown-menu → Menu;context-menu;menubar)
标准映射:Label → GroupLabel,ItemIndicator → CheckboxItemIndicator/RadioItemIndicator,Sub → SubmenuRoot,SubTrigger → SubmenuTrigger,Content → Portal > Positioner > Popup,SubContent 基于 Content 组件重建;Content 上聚合(hoist)align/alignOffset/side/sideOffset。SubTrigger 的打开标记是 data-popup-open。context-menu 有独立子路径(@base-ui/react/context-menu),解剖结构相同。menubar:只有 root 与 checkbox/radio item 是 menubar/menu 原语,其余全部委托给 Menu 包装(Radix 的 Menubar.Menu → Menu.Root)。
select
Label → GroupLabel,Viewport → List,ScrollUp/DownButton → ScrollUp/DownArrow;Icon/ItemIndicator 由 asChild → render;position → Positioner 上的 alignItemWithTrigger(默认 true);变量改为 --available-height / --anchor-width / --transform-origin。
表单控件
Checkbox:1:1。Switch:1:1。Radio group:group 来自 @base-ui/react/radio-group(可调用),item 来自 @base-ui/react/radio(Radio.Root + Radio.Indicator,注意是两个子路径导入)。Slider:结构为 Root > Control > Track > Indicator + Thumbs,thumbAlignment="edge";布局类从 Root 挪到 Control。Toggle/toggle-group:可调用原语;group items 复用 Toggle。
tabs / collapsible / progress / separator / scroll-area / label
Tabs:Trigger → Tab,Content → Panel,新增 aria-disabled:*。Collapsible:Content → Panel。Progress:新增 Track/Label/Value 部分,原语自己计算填充(去掉手动的 translateX)。Separator:可调用,decorative 被移除。Scroll-area:仅 Scrollbar/Thumb 改名。Label:无原语,用原生 <label>。
navigation-menu
Viewport 移出 Root,进入 Portal > Positioner > Popup > Viewport(仓库 golden pair 中的 NavigationMenuPositioner)。Indicator → Icon;viewport 布尔 prop 删除;align 转发到 Positioner。新增 data-instant、data-activation-direction 钩子;变量改为 --positioner-height/width、--popup-height/width。
breadcrumb / marker(Slot 用户)
Slot.Root + asChild → useRender + mergeProps(useRender.ComponentProps<"a">、render prop、state.slot)。
源码对照:golden pair 如何落地这些模式
知识库的「ground truth」来自仓库 apps/v4/registry/bases/ 下的两套完整 registry——radix 版 与 base 版 各含 60 余个同名组件文件,逐对 diff 即得上文的模式与差异。以 breadcrumb 为例,这是「手动 Slot 惯用法 → useRender」的标准样本:
Radix 版 radix/ui/breadcrumb.tsx:
import { Slot } from "radix-ui"
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("cn-breadcrumb-link", className)}
{...props}
/>
)
}
Base 版 base/ui/breadcrumb.tsx:
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
function BreadcrumbLink({
className,
render,
...props
}: useRender.ComponentProps<"a">) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{ className: cn("cn-breadcrumb-link", className) },
props
),
render,
state: { slot: "breadcrumb-link" },
})
}
注意一个细节:当前仓库的 base 版通过 useRender 的 state: { slot: "breadcrumb-link" } 参数注入 data-slot(由 Base UI 运行时转换为 data-slot 属性),而 universal-patterns.md 中「WORKED EXAMPLE」展示的是在 mergeProps 字面量里直接写 data-slot 的早期写法。两者机制等价,阅读知识库代码示例时应以仓库最新 golden pair 的实际写法为准。
Dialog 一对照应了「定位模型」改写:radix/ui/dialog.tsx 的 DialogPrimitive.Content 变为 base/ui/dialog.tsx 的 DialogPrimitive.Popup;Overlay 变为 Backdrop;DialogPrimitive.Close 的 asChild 变为 render prop(base 版 L60-L68)——三个通用模式在同一次迁移中同时出现。
陷阱一:Slot → useRender 工作示例(mergeProps 的 data-* 类型坑)
知识库给出的完整对照(breadcrumb link 场景):
Radix 侧:
import { Slot } from "radix-ui"
function BreadcrumbLink({ asChild, className, ...props }: React.ComponentProps<"a"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "a"
return <Comp data-slot="breadcrumb-link" className={cn("...", className)} {...props} />
}
Base UI 侧:
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
function BreadcrumbLink({ className, render, ...props }: useRender.ComponentProps<"a">) {
return useRender({
defaultTagName: "a",
render,
props: mergeProps<"a">(
// 陷阱:data-* 属性以对象字面量传入 mergeProps 时
// 无法通过 excess-property 检查(只在 JSX 里被特殊处理),必须 cast:
{ "data-slot": "breadcrumb-link", className: cn("...", className) } as React.ComponentProps<"a">,
props
),
})
}
两条规则必须同时遵守:
- 该模式只用于非按钮的多态组件(breadcrumb link、marker、badge、item 等)。
button.tsx应迁移到真正的@base-ui/react/button原语——它原生接受render(这是文档明确记录的 dry-run 修正结论); - 凡向
mergeProps传入含data-*键的对象字面量,一律 cast 为as React.ComponentProps<"tag">,否则 tsc 会对每一个报错。
陷阱二:Positioner props —— Pick 意味着必须转发
当 wrapper 用
Pick<XPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">
对外暴露定位 prop 时,必须在 wrapper 内逐个解构这些 prop 并显式传给 <XPrimitive.Positioner>。若忘记,它们会顺着 ...props 落到 Popup 上(错误的 DOM 节点),定位功能静默失效——JSX 层面没有任何类型错误能捕获这个问题,只能靠 wrapper 自身的解构纪律和浏览器实测发现。文档为此给出每个 overlay wrapper 的检查清单:声明 → 解构 → 转发,三步全做,每次如此。
文档验证 TODO(规格定稿前)
知识库诚实地列出了尚未闭环的验证项,使用者引用时应注意:
- Popover Anchor:确认 Base UI 无 anchor 等价物(Positioner 可能接受
anchorprop;仓库 wrapper 只是移除了该部分); - 回调签名:Radix 的
onOpenChange(open)与 Base UI 的onOpenChange(open, event, reason)风格存在差异;wrapper 直接透传,pair diff 看不到这类差异,需逐原语核对; - 未被 golden pair 覆盖的原语:Toast、Toolbar、Form/Field/Fieldset、OTP Field——只能依据官方文档撰写规格;
- 菜单/select 的受控 prop 名(
open、value、highlighted)以及defaultChecked/checked的细节; - 焦点/关闭行为的调节项(
onInteractOutside、onEscapeKeyDown→ Base UI 等价物),仓库 wrapper 并未暴露它们。
与迁移技能工作流的关系
universal-patterns.md 是 SKILL.md 定义的迁移流程中的「转换引擎」核心。SKILL.md 的策略是:shadcn 项目优先走 golden pair(直接以 registry 中对应样式的 base 变体为目标,整仓模式用 shadcn add <component> --overwrite,渐进模式写入 <component>-base.tsx,自定义文件用 git merge-file 三方合并回放 diff);只有非 shadcn 项目、手写 Radix 组合或未知风格的项目,才完全依赖本文描述的转换引擎——即本文件的导入/asChild/Positioner/data 属性/CSS 变量改写、overlays.md、menus.md 等逐族 prop 表、class-mapping.md 的类名改写与 wrapper-shapes.md 的目标形状。同时,SKILL.md 的硬规则(不碰 cmdk/vaul/sonner 等非 Radix 库、button 必须用真原语、行为差异只标记不静默修补)也是执行本文模式时的约束条件。
小结
这份知识库把 Radix → Base UI 迁移压缩成可机械执行的五个维度:导入形式归一、asChild/Slot → render/useRender、Portal > Content → Portal > Positioner > Popup 定位重构、data-[state=*]/动画类/CSS 变量的系统性改名、以及按上表执行的 Part 重命名。仓库内 apps/v4/registry/bases/ 的 60 余对 golden pair 为每一条规则提供了可逐行对照的实现证据;而文档末尾的验证 TODO 与两条陷阱(mergeProps 的 data-* cast、Positioner props 必须显式转发)则标出了类型系统无法兜底、必须靠浏览器实测确认的边界。
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