首页
/ shadcn ui 仓库 Radix UI 到 Base UI 迁移模式全解:覆盖矩阵、Positioner 定位模型与 Slot → useRender 实战

shadcn ui 仓库 Radix UI 到 Base UI 迁移模式全解:覆盖矩阵、Positioner 定位模型与 Slot → useRender 实战

2026-09-05 15:26:40作者:戚魁泉Nursing

本文以 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/ 技能知识库,其结论基于三路证据(文档开头即声明):

  1. 机械 diffapps/v4/registry/bases/{radix,base}/ui/ 下 61 对组件的逐行对比(第一手事实来源,由仓库团队制作);
  2. radix-ui@1.4.3 的包导出;
  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 迁移中最核心的结构差异:

  • RadixPortal > Content,定位 prop(side、align 等)直接挂在 Content 上;
  • Base UIPortal > Positioner > PopupsidesideOffsetalignalignOffset(以及 select 的 alignItemWithTrigger全部移到 Positioner 上;Popup 才是承载样式的盒子。Positioner 按惯例加 isolate z-50
  • OverlayBackdrop(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.tsxDialogContent 则直接就是 DialogPrimitive.Content(定位能力内置)。同文件中 Overlay 部分也换成了 Backdropbase 版 L26-L37)。

4. 数据属性 / class 钩子

  • 状态属性:data-[state=open]data-opendata-[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.tsxdisabled: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:delayDurationdelay
  • Select:position="popper" | "item-aligned" → Positioner 上的布尔 prop alignItemWithTrigger
  • Slider:新增 thumbAlignment(取 "edge");RangeIndicator + 新增 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 不变;ContentPanel。Trigger 的 disabled:*aria-disabled:*;高度变量改为 --accordion-panel-height;补上 data-starting-style:h-0 data-ending-style:h-0。仓库中这对文件的差异与上述描述完全一致(见上一节引用)。

dialog / alert-dialog / sheet

OverlayBackdropContentPopupClose 保留(asChildrender)。alert-dialog 中 CancelCloseAction 没有对应原语,用普通 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 新增 modalsnapPointsswipeDirection(默认 "down")、showSwipeHandle;原单一 Content 拆为 Viewport > Popup > Contentdata-[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 的 delayDurationdelay;Content 新增 side/align/alignOffset;默认 sideOffset 从 0 变为 4;Arrow 需要显式的按方向定位类。HoverCard:原语改名为 PreviewCard公共包装名保持 HoverCard* 不变)。

menus(dropdown-menu → Menu;context-menu;menubar)

标准映射:LabelGroupLabelItemIndicatorCheckboxItemIndicator/RadioItemIndicatorSubSubmenuRootSubTriggerSubmenuTriggerContentPortal > 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.MenuMenu.Root)。

select

LabelGroupLabelViewportListScrollUp/DownButtonScrollUp/DownArrowIcon/ItemIndicatorasChildrenderposition → 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/radioRadio.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:TriggerTabContentPanel,新增 aria-disabled:*。Collapsible:ContentPanel。Progress:新增 Track/Label/Value 部分,原语自己计算填充(去掉手动的 translateX)。Separator:可调用,decorative 被移除。Scroll-area:仅 Scrollbar/Thumb 改名。Label:无原语,用原生 <label>

navigation-menu

Viewport 移出 Root,进入 Portal > Positioner > Popup > Viewport(仓库 golden pair 中的 NavigationMenuPositioner)。IndicatorIconviewport 布尔 prop 删除;align 转发到 Positioner。新增 data-instantdata-activation-direction 钩子;变量改为 --positioner-height/width--popup-height/width

breadcrumb / marker(Slot 用户)

Slot.Root + asChilduseRender + mergePropsuseRender.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 版通过 useRenderstate: { slot: "breadcrumb-link" } 参数注入 data-slot(由 Base UI 运行时转换为 data-slot 属性),而 universal-patterns.md 中「WORKED EXAMPLE」展示的是在 mergeProps 字面量里直接写 data-slot 的早期写法。两者机制等价,阅读知识库代码示例时应以仓库最新 golden pair 的实际写法为准。

Dialog 一对照应了「定位模型」改写:radix/ui/dialog.tsxDialogPrimitive.Content 变为 base/ui/dialog.tsxDialogPrimitive.PopupOverlay 变为 BackdropDialogPrimitive.CloseasChild 变为 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
    ),
  })
}

两条规则必须同时遵守:

  1. 该模式只用于非按钮的多态组件(breadcrumb link、marker、badge、item 等)。button.tsx 应迁移到真正的 @base-ui/react/button 原语——它原生接受 render(这是文档明确记录的 dry-run 修正结论);
  2. 凡向 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(规格定稿前)

知识库诚实地列出了尚未闭环的验证项,使用者引用时应注意:

  1. Popover Anchor:确认 Base UI 无 anchor 等价物(Positioner 可能接受 anchor prop;仓库 wrapper 只是移除了该部分);
  2. 回调签名:Radix 的 onOpenChange(open) 与 Base UI 的 onOpenChange(open, event, reason) 风格存在差异;wrapper 直接透传,pair diff 看不到这类差异,需逐原语核对;
  3. 未被 golden pair 覆盖的原语:Toast、Toolbar、Form/Field/Fieldset、OTP Field——只能依据官方文档撰写规格;
  4. 菜单/select 的受控 prop 名openvaluehighlighted)以及 defaultChecked/checked 的细节;
  5. 焦点/关闭行为的调节项onInteractOutsideonEscapeKeyDown → Base UI 等价物),仓库 wrapper 并未暴露它们。

与迁移技能工作流的关系

universal-patterns.mdSKILL.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.mdmenus.md 等逐族 prop 表、class-mapping.md 的类名改写与 wrapper-shapes.md 的目标形状。同时,SKILL.md 的硬规则(不碰 cmdk/vaul/sonner 等非 Radix 库、button 必须用真原语、行为差异只标记不静默修补)也是执行本文模式时的约束条件。

小结

这份知识库把 Radix → Base UI 迁移压缩成可机械执行的五个维度:导入形式归一、asChild/Slotrender/useRenderPortal > ContentPortal > Positioner > Popup 定位重构、data-[state=*]/动画类/CSS 变量的系统性改名、以及按上表执行的 Part 重命名。仓库内 apps/v4/registry/bases/ 的 60 余对 golden pair 为每一条规则提供了可逐行对照的实现证据;而文档末尾的验证 TODO 与两条陷阱(mergeProps 的 data-* cast、Positioner props 必须显式转发)则标出了类型系统无法兜底、必须靠浏览器实测确认的边界。

登录后查看全文
热门项目推荐
相关项目推荐