首页
/ shadcn/ui:Radix 迁移 Base UI 的类字符串改写完全指南(class-mapping 层)

shadcn/ui:Radix 迁移 Base UI 的类字符串改写完全指南(class-mapping 层)

2026-09-04 19:31:42作者:尤峻淳Whitney

本篇基于 shadcn/ui 官方仓库(ui/ui)内置的迁移知识库 skills/migrate-radix-to-base/,详解其中 class-mapping.md 所定义的"第 2 层"迁移规则:把所有基于 Radix data-state 的 Tailwind 类选择器、动画惯用法与 --radix-* CSS 变量,安全地、机械地改写为 Base UI(@base-ui/react)对应的存在性属性(data-opendata-checked)与 data-starting-style / data-ending-style 过渡体系。读完本文,你能够独立完成任意 shadcn 组件包装层与应用代码中类字符串的 Radix → Base UI 批量改写,并理解每一条规则背后的渲染元素与属性差异。

一、class-mapping 在迁移体系中的位置

shadcn/ui 仓库在 skills/migrate-radix-to-base/SKILL.md 中定义了一套完整的 Radix → Base UI 迁移流程,其知识体系分为多个层次:

  • 变换引擎(transformation engine):处理导入改写(radix-ui@radix-ui/react-* 两种形态)、asChildrenderPortal > Positioner > Popup 结构重组等,规则见 universal-patterns.md
  • 各组件族的 props 映射表:覆盖浮层(overlays.md)、表单控件(form-controls.md)、菜单、披露组件、展示组件等;
  • 类字符串改写(layer 2):即本文主角 class-mapping.md,它专门负责"不动 JSX 结构、只改类字符串"的那部分工作。

原始文档对这一层的定位非常明确:

Apply these across ALL class strings (className, cva definitions, cn calls), including app code. They are safe, mechanical rewrites.

也就是说,这批改写必须作用于所有类字符串——组件包装层里的 className 属性、cva(class-variance-authority)变体定义、cn() 调用——并且应用代码(app code)中出现的类同样要改写。它们被设计为"安全、机械"的替换:不需要理解组件行为,逐条查表即可,这也是它们能作为自动化迁移流水线中一个独立图层运行的原因。

该知识库本身有据可查:universal-patterns.md 说明其"地面真值"(ground truth)来自对仓库内 61 组组件配对 apps/v4/registry/bases/{radix,base}/ui/ 的机械 diff,以及 radix-ui@1.4.3 包导出与 @base-ui/react@1.6.0 文档索引的核对。因此下面每条改写规则都可以在仓库的真实注册表(registry)源码中找到实例印证。

二、Data-attribute 选择器改写:从 data-state 到存在性属性

Radix 通过单个 data-state 属性的取值来表达组件状态(data-state="open"data-state="checked"……),而 Base UI 改用存在性属性(presence attribute):属性存在即代表该状态。这导致 Tailwind 的数据属性变体语法整体变化。以下是原文档的完整改写表(原样保留):

Radix 模式 Base UI 模式
data-[state=open]: data-open:
data-[state=closed]: data-closed:
data-[state=checked]: data-checked:
data-[state=unchecked]: data-unchecked:
data-[state=active]:(tabs) data-active:
data-[state=on]:(toggle) data-pressed:
data-[highlighted]: data-highlighted:(不变)
data-[disabled]: data-disabled:(不变)
data-[side=...]: data-[side=...]:(不变,仍是参数化选择器)
group-data-[state=open] / peer-data-[state=open] group-data-open / peer-data-open
子菜单触发器打开标记 data-[state=open]: data-popup-open:

三点值得注意:

  1. 不是所有选择器都要改data-[highlighted]data-[disabled] 在两侧写法相同;data-[side=...] 依旧是需要参数化的选择器(Base UI 扩展了取值,见下文动画一节),原样保留即可。
  2. group- / peer- 前缀变体同样适用:只要基础选择器从 data-[state=open] 变成 data-open,带前缀的复合变体必须同步改写,否则"打开时兄弟元素变色"这类联动样式会静默失效。
  3. 新增的 Base UI 钩子是 data-popup-open:Radix 中"子菜单触发器处于打开状态"依赖 data-[state=open],Base UI 则使用专门的 data-popup-open 存在性属性(select 的 Icon 也暴露该属性用于"打开时旋转"样式,见 form-controls.md)。

三、动画惯用法:从 keyframes 到 transition + 起终点样式

这是本次迁移中唯一不能 1:1 照抄的一类改写。Radix 侧的进入/退出动画依赖 tw-animate 的 keyframes 工具类,配合 data-state 选择器触发:

data-[state=open]:animate-in data-[state=open]:fade-in-0
data-[state=closed]:animate-out data-[state=closed]:fade-out-0

Base UI 侧则采用 CSS transition 机制,用 data-starting-style: / data-ending-style: 两个钩子声明过渡的起点与终点样式:

transition-[opacity,transform] data-starting-style:opacity-0 data-ending-style:opacity-0

(如需位移/缩放效果,再补充对应的 translate/scale 起终点声明。)原文档明确要求:不要逐条翻译 animate-in/out 工具类,要用 data-starting-style: / data-ending-style: 重新表述意图;当原实现按侧边做滑入时,保留 data-[side=...]data-[swipe-direction=...] 的参数化写法。

仓库中的真实注册表代码给出了教科书级的示范——base 风格的 sheet 组件 apps/v4/registry/bases/base/ui/sheet.tsx

// 遮罩层(L31)
"cn-sheet-overlay fixed inset-0 z-50 transition-opacity duration-150
 data-ending-style:opacity-0 data-starting-style:opacity-0"

// 面板(L56 起):按 data-[side=...] 参数化平移
"cn-sheet-content data-ending-style:opacity-0 data-starting-style:opacity-0
 data-[side=bottom]:data-starting-style:translate-y-[2.5rem]
 data-[side=bottom]:data-ending-style:translate-y-[2.5rem]
 data-[side=left]:data-starting-style:translate-x-[-2.5rem]
 data-[side=left]:data-ending-style:translate-x-[-2.5rem]
 data-[side=right]:data-starting-style:translate-x-[2.5rem]
 data-[side=right]:data-ending-style:translate-x-[2.5rem]
 data-[side=top]:data-starting-style:translate-y-[-2.5rem]
 data-[side=top]:data-ending-style:translate-y-[-2.5rem]"

可以观察到两个关键细节:其一,遮罩层只需 opacity 过渡;其二,面板按 data-[side=...] 为四个方向分别声明起/终点 translate——这正是"保留参数化"规则的落地形态。另外 Base UI 自身会在退出动画期间保持弹窗挂载,并额外暴露 onOpenChangeCompleteactionsRef.current.unmount() 供外部受控动画(overlays.md),因此 Radix 时代的 forceMount 在多数情况下可以直接丢弃。

四、CSS 变量改写表

Radix 使用带组件前缀的 --radix-<comp>-* 自定义属性,Base UI 使用一组无组件前缀的通用变量。完整对应关系(原文档全表):

Radix 变量 Base UI 变量
--radix-<comp>-content-transform-origin --transform-origin
--radix-<comp>-content-available-height --available-height
--radix-<comp>-content-available-width --available-width
--radix-<comp>-trigger-width --anchor-width
--radix-<comp>-trigger-height --anchor-height
--radix-accordion-content-height --accordion-panel-height
--radix-collapsible-content-height --collapsible-panel-height
--radix-navigation-menu-viewport-height/width --positioner-height / --positioner-width

补充两个来源细节:

  • 挂载点变化:Radix 把这类变量设在 Content 上(select 的 popper 模式),Base UI 统一设在 Select.Positioner 等位置器部件上(form-controls.md 的 select CSS 变量表)。因此改写时除了重命名,还要确认变量读取发生在正确的 DOM 节点上。
  • navigation menu 有额外变量universal-patterns.md 指出 nav-menu 侧还引入了 --popup-height/width--available-width,迁移该组件时需要一并对齐。

五、元素类型变化会让伪类变体"死亡"

这是 class-mapping 中唯一涉及"类变死代码"的规则。当某个部件的渲染元素从表单控件(form control)变为普通元素时,disabled::disabled 这类伪类变体将永远无法命中,成为死代码。

具体而言,Base UI 的 checkbox / switch / radio Root 渲染的是 <span>(外加隐藏 <input>),而 Radix 侧渲染的是 <button>。因此 Radix 代码里写 disabled:opacity-50 的地方,在 Base UI 下必须替换为 data-disabled: 等价类。

仓库源码提供了最直接的证据。对比 Radix 与 base 两套注册表中的 checkbox 包装层:

  • Radix 版 apps/v4/registry/bases/radix/ui/checkbox.tsx"cn-checkbox peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50",导入为 import { Checkbox as CheckboxPrimitive } from "radix-ui"
  • Base UI 版 apps/v4/registry/bases/base/ui/checkbox.tsx:类字符串几乎原样保留,导入换为 import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox",props 类型从 React.ComponentProps<typeof CheckboxPrimitive.Root> 变为 CheckboxPrimitive.Root.Props

注意原文档特意点名的"上游小瑕疵":shadcn 的 base 注册表 checkbox 至今仍携带已死的 disabled:*——如上面 base/ui/checkbox.tsx 第 13 行可见,disabled:cursor-not-allowed disabled:opacity-50 仍在类列表中。因为 Root 已渲染 <span>,这两个变体实际上不会命中。文档明确要求:把它当作上游 quirks 记录,不要把它当作模式去复制;在自己的迁移中应替换为 data-disabled: 等价类(Base UI 的 data-disabled 属性会在根元素上正常出现,见 form-controls.md 的 checkbox data-attribute 表)。

六、Disabled 状态钩子:aria-disableddisabled 并存

与上一条相反,有些 Base UI 触发器不渲染 disabled 属性,而是以 aria-disabled 暴露禁用状态——典型是 accordion trigger 与 tabs tab(universal-patterns.md 的 accordion 小节同样指出:Trigger 的 disabled:* 需改为 aria-disabled:*)。

改写规则:如果 Radix 代码用 disabled:opacity-50 做禁用置灰,迁移时应按目标包装层的参照文件添加或替换为 aria-disabled:opacity-50。判断依据不是组件名,而是参照(golden)包装层实际渲染的元素与属性——这正是 SKILL.md 中"参照文件为准、逐 wrapper 核对"原则的体现。

七、实操落地建议

结合 SKILL.md 的流程,本图层规则的实际使用方式是:

  1. 确定改写范围:所有 className、cva 定义、cn() 调用,包含应用代码,一处不漏;
  2. 逐表执行:第二节属性选择器表 → 第三节动画惯用法(唯一需要重表述意图的一节)→ 第四节 CSS 变量表 → 第五/六节按包装层参照文件甄别 disabled: / aria-disabled:
  3. 用仓库 golden 配对做对照基准:仓库在 apps/v4/registry/bases/radix/ui/apps/v4/registry/bases/base/ui/ 下维护了 61 组 Radix/Base 组件配对,任何不确定的类改写都可以在这两棵目录里找到同组件的两侧真实写法(如本文引用的 checkbox、sheet 实例);
  4. 残留扫描:SKILL.md 要求每个文件迁移后执行 grep -n "radix-ui\|@radix-ui" 级别的残留检查。对应到本图层,建议同样 grep 一遍 data-[state=--radix-animate-inanimate-out,确认旧惯用法没有残留——"合并干净"不等于"文件干净"。

八、小结与延伸阅读

class-mapping.md 虽然篇幅不长,却覆盖了 Radix → Base UI 迁移中所有"纯类字符串"层面的改写规则:data-attribute 选择器映射、keyframes → transition 的动画惯用法转换、--radix-* CSS 变量重命名、元素变化导致的伪类变体失效、以及 aria-disabled 钩子。它与同目录下的 universal-patterns.md(导入/结构/asChild)、overlays.mdform-controls.md(逐 prop 迁移表)共同构成了一套可机械执行的迁移规范。若你正在把 shadcn 项目从 radix-<style> 迁往 base-<style>,建议按"结构改写 → props 改写 → 类字符串改写(本文)→ 残留扫描"的顺序推进,并以仓库内 bases/{radix,base}/ui/ 的组件配对作为最终校验基准。

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